diff --git a/data/dejavu-sans/DejaVu Fonts License.txt b/data/dejavu-sans/DejaVu Fonts License.txt new file mode 100644 index 0000000..6939980 --- /dev/null +++ b/data/dejavu-sans/DejaVu Fonts License.txt @@ -0,0 +1,97 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. \ No newline at end of file diff --git a/data/dejavu-sans/DejaVuSans-Bold.ttf b/data/dejavu-sans/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..6d65fa7 Binary files /dev/null and b/data/dejavu-sans/DejaVuSans-Bold.ttf differ diff --git a/data/dejavu-sans/DejaVuSans-BoldOblique.ttf b/data/dejavu-sans/DejaVuSans-BoldOblique.ttf new file mode 100644 index 0000000..753f2d8 Binary files /dev/null and b/data/dejavu-sans/DejaVuSans-BoldOblique.ttf differ diff --git a/data/dejavu-sans/DejaVuSans-ExtraLight.ttf b/data/dejavu-sans/DejaVuSans-ExtraLight.ttf new file mode 100644 index 0000000..b09f32d Binary files /dev/null and b/data/dejavu-sans/DejaVuSans-ExtraLight.ttf differ diff --git a/data/dejavu-sans/DejaVuSans-Oblique.ttf b/data/dejavu-sans/DejaVuSans-Oblique.ttf new file mode 100644 index 0000000..999bac7 Binary files /dev/null and b/data/dejavu-sans/DejaVuSans-Oblique.ttf differ diff --git a/data/dejavu-sans/DejaVuSans.ttf b/data/dejavu-sans/DejaVuSans.ttf new file mode 100644 index 0000000..e5f7eec Binary files /dev/null and b/data/dejavu-sans/DejaVuSans.ttf differ diff --git a/data/dejavu-sans/DejaVuSansCondensed-Bold.ttf b/data/dejavu-sans/DejaVuSansCondensed-Bold.ttf new file mode 100644 index 0000000..22987c6 Binary files /dev/null and b/data/dejavu-sans/DejaVuSansCondensed-Bold.ttf differ diff --git a/data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf b/data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf new file mode 100644 index 0000000..f5fa0ca Binary files /dev/null and b/data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf differ diff --git a/data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf b/data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf new file mode 100644 index 0000000..7fde907 Binary files /dev/null and b/data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf differ diff --git a/data/dejavu-sans/DejaVuSansCondensed.ttf b/data/dejavu-sans/DejaVuSansCondensed.ttf new file mode 100644 index 0000000..3259bc2 Binary files /dev/null and b/data/dejavu-sans/DejaVuSansCondensed.ttf differ diff --git a/main_window_old.py b/main_window_old.py new file mode 100644 index 0000000..2417b20 --- /dev/null +++ b/main_window_old.py @@ -0,0 +1,5529 @@ +# --- Standard Library Imports --- +import sys +import os +import time +import queue +import traceback +import html +import http +import json +import re +import subprocess +import datetime +import requests +import unicodedata +from collections import deque +import threading +from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError +from urllib .parse import urlparse + +# --- PyQt5 Imports --- +from PyQt5.QtGui import QIcon, QIntValidator, QDesktopServices +from PyQt5.QtWidgets import ( + QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, + QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton, + QButtonGroup, QCheckBox, QSplitter, QGroupBox, QDialog, QStackedWidget, + QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame, + QMainWindow, QAction +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker + +# --- Local Application Imports --- +from ..services.drive_downloader import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file +from ..core.workers import DownloadThread as BackendDownloadThread +from ..core.workers import PostProcessorWorker +from ..core.workers import PostProcessorSignals +from ..core.api_client import download_from_api +from ..core.manager import DownloadManager +from .assets import get_app_icon_object +from ..config.constants import * +from ..utils.file_utils import KNOWN_NAMES, clean_folder_name +from ..utils.network_utils import extract_post_info, prepare_cookies_for_request +from ..i18n.translator import get_translation +from .dialogs.EmptyPopupDialog import EmptyPopupDialog +from .dialogs.CookieHelpDialog import CookieHelpDialog +from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog +from .dialogs.KnownNamesFilterDialog import KnownNamesFilterDialog +from .dialogs.HelpGuideDialog import HelpGuideDialog +from .dialogs.FutureSettingsDialog import FutureSettingsDialog +from .dialogs.ErrorFilesDialog import ErrorFilesDialog +from .dialogs.DownloadHistoryDialog import DownloadHistoryDialog +from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog +from .dialogs.FavoritePostsDialog import FavoritePostsDialog +from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog +from .dialogs.ConfirmAddAllDialog import ConfirmAddAllDialog + +class DynamicFilterHolder: + """A thread-safe class to hold and update character filters during a download.""" + def __init__(self, initial_filters=None): + self.lock = threading.Lock() + self._filters = initial_filters if initial_filters is not None else [] + + def get_filters(self): + with self.lock: + return [dict(f) for f in self._filters] + + def set_filters(self, new_filters): + with self.lock: + self._filters = [dict(f) for f in (new_filters if new_filters else [])] + + +class PostProcessorSignals(QObject): + """A collection of signals for the DownloaderApp to communicate with itself across threads.""" + progress_signal = pyqtSignal(str) + file_download_status_signal = pyqtSignal(bool) + external_link_signal = pyqtSignal(str, str, str, str, str) + file_progress_signal = pyqtSignal(str, object) + file_successfully_downloaded_signal = pyqtSignal(dict) + missed_character_post_signal = pyqtSignal(str, str) + finished_signal = pyqtSignal(int, int, bool, list) + retryable_file_failed_signal = pyqtSignal(list) + permanent_file_failed_signal = pyqtSignal(list) + +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 ) + file_successfully_downloaded_signal =pyqtSignal (dict ) + post_processed_for_history_signal =pyqtSignal (dict ) + finished_signal =pyqtSignal (int ,int ,bool ,list ) + external_link_signal =pyqtSignal (str ,str ,str ,str ,str ) + file_progress_signal =pyqtSignal (str ,object ) + + + def __init__(self): + super().__init__() + self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN) + + # --- CORRECT PATH DEFINITION --- + # This block correctly determines the application's base directory whether + # it's running from source or as a frozen executable. + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # Path for PyInstaller one-file bundle + self.app_base_dir = os.path.dirname(sys.executable) + else: + # Path for running from source code + self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + + # All file paths will now correctly use the single, correct app_base_dir + self.config_file = os.path.join(self.app_base_dir, "appdata", "Known.txt") + self.session_file_path = os.path.join(self.app_base_dir, "appdata", "session.json") + self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json") + + self.download_thread = None + self.thread_pool = None + self.cancellation_event = threading.Event() + self.session_lock = threading.Lock() + self.interrupted_session_data = None + self.is_restore_pending = False + self.external_link_download_thread = None + self.pause_event = threading.Event() + self.active_futures = [] + self.total_posts_to_process = 0 + self.dynamic_character_filter_holder = DynamicFilterHolder() + self.processed_posts_count = 0 + self.creator_name_cache = {} + self.log_signal.emit(f"ℹ️ App base directory: {self.app_base_dir}") + self.log_signal.emit(f"ℹ️ Persistent history file path set to: {self.persistent_history_file}") + + # --- The rest of your __init__ method continues from here --- + self.last_downloaded_files_details = deque(maxlen=3) + self.download_history_candidates = deque(maxlen=8) + self.final_download_history_entries = [] + self.favorite_download_queue = deque() + self.is_processing_favorites_queue = False + self.download_counter = 0 + self.permanently_failed_files_for_dialog = [] + self.last_link_input_text_for_queue_sync = "" + self.is_fetcher_thread_running = False + self._restart_pending = False + self.download_history_log = deque(maxlen=50) + self.skip_counter = 0 + self.all_kept_original_filenames = [] + self.cancellation_message_logged_this_session = False + self.favorite_scope_toggle_button = None + self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION + self.manga_mode_checkbox = None + self.selected_cookie_filepath = None + self.retryable_failed_files_info = [] + self.is_paused = False + self.worker_to_gui_queue = queue.Queue() + self.gui_update_timer = QTimer(self) + self.actual_gui_signals = PostProcessorSignals() + self.worker_signals = PostProcessorSignals() + self.prompt_mutex = QMutex() + self._add_character_response = None + self._original_scan_content_tooltip = ("If checked, the downloader will scan the HTML content of posts for image URLs (from tags or direct links).\n" + "now This includes resolving relative paths from tags to full URLs.\n" + "Relative paths in tags (e.g., /data/image.jpg) will be resolved to full URLs.\n" + "Useful for cases where images are in the post description but not in the API's file/attachment list.") + self.downloaded_files = set() + self.downloaded_files_lock = threading.Lock() + self.downloaded_file_hashes = set() + self.downloaded_file_hashes_lock = threading.Lock() + self.show_external_links = False + self.external_link_queue = deque() + self._is_processing_external_link_queue = False + self._current_link_post_title = None + self.extracted_links_cache = [] + self.manga_rename_toggle_button = None + self.favorite_mode_checkbox = None + self.url_or_placeholder_stack = None + self.url_input_widget = None + self.url_placeholder_widget = None + self.favorite_action_buttons_widget = None + self.favorite_mode_artists_button = None + self.favorite_mode_posts_button = None + self.standard_action_buttons_widget = None + self.bottom_action_buttons_stack = None + self.main_log_output = None + self.external_log_output = None + self.log_splitter = None + self.main_splitter = None + self.reset_button = None + self.progress_log_label = None + self.log_verbosity_toggle_button = None + self.missed_character_log_output = None + self.log_view_stack = None + self.current_log_view = 'progress' + self.link_search_input = None + self.link_search_button = None + self.export_links_button = None + self.radio_only_links = None + self.radio_only_archives = None + self.missed_title_key_terms_count = {} + self.missed_title_key_terms_examples = {} + self.logged_summary_for_key_term = set() + self.STOP_WORDS = set(["a", "an", "the", "is", "was", "were", "of", "for", "with", "in", "on", "at", "by", "to", "and", "or", "but", "i", "you", "he", "she", "it", "we", "they", "my", "your", "his", "her", "its", "our", "their", "com", "net", "org", "www"]) + self.already_logged_bold_key_terms = set() + self.missed_key_terms_buffer = [] + self.char_filter_scope_toggle_button = None + self.skip_words_scope = SKIP_SCOPE_POSTS + self.char_filter_scope = CHAR_SCOPE_TITLE + self.manga_filename_style = self.settings.value(MANGA_FILENAME_STYLE_KEY, STYLE_POST_TITLE, type=str) + self.current_theme = self.settings.value(THEME_KEY, "dark", type=str) + self.only_links_log_display_mode = LOG_DISPLAY_LINKS + self.mega_download_log_preserved_once = False + self.allow_multipart_download_setting = False + self.use_cookie_setting = False + self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool) + self.cookie_text_setting = "" + self.current_selected_language = self.settings.value(LANGUAGE_KEY, "en", type=str) + + print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}") + + try: + base_path_for_icon = "" + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + base_path_for_icon = sys._MEIPASS + else: + base_path_for_icon = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + + icon_path_for_window = os.path.join(base_path_for_icon, 'assets', 'Kemono.ico') + + if os.path.exists(icon_path_for_window): + self.setWindowIcon(QIcon(icon_path_for_window)) + else: + if getattr(sys, 'frozen', False): + executable_dir = os.path.dirname(sys.executable) + fallback_icon_path = os.path.join(executable_dir, 'assets', 'Kemono.ico') + if os.path.exists(fallback_icon_path): + self.setWindowIcon(QIcon(fallback_icon_path)) + else: + self.log_signal.emit(f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window} or {fallback_icon_path}") + else: + self.log_signal.emit(f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window}") + except Exception as e_icon_app: + self.log_signal.emit(f"❌ Error setting main window icon in DownloaderApp init: {e_icon_app}") + + self.url_label_widget = None + self.download_location_label_widget = None + self.remove_from_filename_label_widget = None + self.skip_words_label_widget = None + self.setWindowTitle("Kemono Downloader v6.0.0") + 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.") + if hasattr(self, 'character_input'): + self.character_input.setToolTip(self._tr("character_input_tooltip", "Enter character names (comma-separated)...")) + self.log_signal.emit(f"ℹ️ Manga filename style loaded: '{self.manga_filename_style}'") + self.log_signal.emit(f"ℹ️ Skip words scope loaded: '{self.skip_words_scope}'") + self.log_signal.emit(f"ℹ️ Character filter scope set to default: '{self.char_filter_scope}'") + self.log_signal.emit(f"ℹ️ Multi-part download defaults to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'}") + self.log_signal.emit(f"ℹ️ Cookie text defaults to: Empty on launch") + self.log_signal.emit(f"ℹ️ 'Use Cookie' setting defaults to: Disabled on launch") + self.log_signal.emit(f"ℹ️ Scan post content for images defaults to: {'Enabled' if self.scan_content_images_setting else 'Disabled'}") + self.log_signal.emit(f"ℹ️ Application language loaded: '{self.current_selected_language.upper()}' (UI may not reflect this yet).") + self._retranslate_main_ui() + self._load_persistent_history() + self._load_saved_download_location() + self._update_button_states_and_connections() + self._check_for_interrupted_session() + + + def get_checkbox_map(self): + """Returns a mapping of checkbox attribute names to their corresponding settings key.""" + return { + 'skip_zip_checkbox': 'skip_zip', + 'skip_rar_checkbox': 'skip_rar', + 'download_thumbnails_checkbox': 'download_thumbnails', + 'compress_images_checkbox': 'compress_images', + 'use_subfolders_checkbox': 'use_subfolders', + 'use_subfolder_per_post_checkbox': 'use_post_subfolders', + 'use_multithreading_checkbox': 'use_multithreading', + 'external_links_checkbox': 'show_external_links', + 'keep_duplicates_checkbox': 'keep_in_post_duplicates', + 'date_prefix_checkbox': 'use_date_prefix_for_subfolder', + 'manga_mode_checkbox': 'manga_mode_active', + 'scan_content_images_checkbox': 'scan_content_for_images', + 'use_cookie_checkbox': 'use_cookie', + 'favorite_mode_checkbox': 'favorite_mode_active' + } + + def _get_current_ui_settings_as_dict(self, api_url_override=None, output_dir_override=None): + """Gathers all relevant UI settings into a JSON-serializable dictionary.""" + settings = {} + + settings['api_url'] = api_url_override if api_url_override is not None else self.link_input.text().strip() + settings['output_dir'] = output_dir_override if output_dir_override is not None else self.dir_input.text().strip() + settings['character_filter_text'] = self.character_input.text().strip() + settings['skip_words_text'] = self.skip_words_input.text().strip() + settings['remove_words_text'] = self.remove_from_filename_input.text().strip() + settings['custom_folder_name'] = self.custom_folder_input.text().strip() + settings['cookie_text'] = self.cookie_text_input.text().strip() + if hasattr(self, 'manga_date_prefix_input'): + settings['manga_date_prefix'] = self.manga_date_prefix_input.text().strip() + + try: settings['num_threads'] = int(self.thread_count_input.text().strip()) + except (ValueError, AttributeError): settings['num_threads'] = 4 + try: settings['start_page'] = int(self.start_page_input.text().strip()) if self.start_page_input.text().strip() else None + except (ValueError, AttributeError): settings['start_page'] = None + try: settings['end_page'] = int(self.end_page_input.text().strip()) if self.end_page_input.text().strip() else None + except (ValueError, AttributeError): settings['end_page'] = None + + for checkbox_name, key in self.get_checkbox_map().items(): + if checkbox := getattr(self, checkbox_name, None): settings[key] = checkbox.isChecked() + + settings['filter_mode'] = self.get_filter_mode() + settings['only_links'] = self.radio_only_links.isChecked() + + settings['skip_words_scope'] = self.skip_words_scope + settings['char_filter_scope'] = self.char_filter_scope + settings['manga_filename_style'] = self.manga_filename_style + settings['allow_multipart_download'] = self.allow_multipart_download_setting + + return settings + + + def _tr (self ,key ,default_text =""): + """Helper to get translation based on current app language for the main window.""" + if callable (get_translation ): + return get_translation (self .current_selected_language ,key ,default_text ) + return default_text + + def _load_saved_download_location (self ): + saved_location =self .settings .value (DOWNLOAD_LOCATION_KEY ,"",type =str ) + if saved_location and os .path .isdir (saved_location ): + if hasattr (self ,'dir_input')and self .dir_input : + self .dir_input .setText (saved_location ) + self .log_signal .emit (f"ℹ️ Loaded saved download location: {saved_location }") + else : + self .log_signal .emit (f"⚠️ Found saved download location '{saved_location }', but dir_input not ready.") + elif saved_location : + self .log_signal .emit (f"⚠️ Found saved download location '{saved_location }', but it's not a valid directory. Ignoring.") + + def _check_for_interrupted_session(self): + """Checks for an incomplete session file on startup and prepares the UI for restore if found.""" + if os.path.exists(self.session_file_path): + try: + with open(self.session_file_path, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + if "ui_settings" not in session_data or "download_state" not in session_data: + raise ValueError("Invalid session file structure.") + + failed_files_from_session = session_data.get('download_state', {}).get('permanently_failed_files', []) + if failed_files_from_session: + self.permanently_failed_files_for_dialog.clear() + self.permanently_failed_files_for_dialog.extend(failed_files_from_session) + self.log_signal.emit(f"ℹ️ Restored {len(failed_files_from_session)} failed file entries from the previous session.") + + self.interrupted_session_data = session_data + self.log_signal.emit("ℹ️ Incomplete download session found. UI updated for restore.") + self._prepare_ui_for_restore() + + except Exception as e: + self.log_signal.emit(f"❌ Error reading session file: {e}. Deleting corrupt session file.") + os.remove(self.session_file_path) + self.interrupted_session_data = None + self.is_restore_pending = False + + def _prepare_ui_for_restore(self): + """Configures the UI to a 'restore pending' state.""" + if not self.interrupted_session_data: + return + + self.log_signal.emit(" UI updated for session restore.") + settings = self.interrupted_session_data.get("ui_settings", {}) + self._load_ui_from_settings_dict(settings) + + self.is_restore_pending = True + self._update_button_states_and_connections() # Update buttons for restore state, UI remains editable + + def _clear_session_and_reset_ui(self): + """Clears the session file and resets the UI to its default state.""" + self._clear_session_file() + self.interrupted_session_data = None + self.is_restore_pending = False + self._update_button_states_and_connections() # Ensure buttons are updated to idle state + self.reset_application_state() + + def _clear_session_file(self): + """Safely deletes the session file.""" + if os.path.exists(self.session_file_path): + try: + os.remove(self.session_file_path) + self.log_signal.emit("ℹ️ Interrupted session file cleared.") + except Exception as e: + self.log_signal.emit(f"❌ Failed to clear session file: {e}") + + def _save_session_file(self, session_data): + """Safely saves the session data to the session file using an atomic write pattern.""" + temp_session_file_path = self.session_file_path + ".tmp" + try: + with open(temp_session_file_path, 'w', encoding='utf-8') as f: + json.dump(session_data, f, indent=2) + os.replace(temp_session_file_path, self.session_file_path) + except Exception as e: + self.log_signal.emit(f"❌ Failed to save session state: {e}") + if os.path.exists(temp_session_file_path): + try: + os.remove(temp_session_file_path) + except Exception as e_rem: + self.log_signal.emit(f"❌ Failed to remove temp session file: {e_rem}") + + def _update_button_states_and_connections(self): + """ + Updates the text and click connections of the main action buttons + based on the current application state (downloading, paused, restore pending, idle). + """ + # Disconnect all signals first to prevent multiple connections + try: self.download_btn.clicked.disconnect() + except TypeError: pass + try: self.pause_btn.clicked.disconnect() + except TypeError: pass + try: self.cancel_btn.clicked.disconnect() + except TypeError: pass + + is_download_active = self._is_download_active() + + if self.is_restore_pending: + # State: Restore Pending + self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) + self.download_btn.setEnabled(True) + self.download_btn.clicked.connect(self.start_download) + self.download_btn.setToolTip(self._tr("start_download_discard_tooltip", "Click to start a new download, discarding the previous session.")) + + self.pause_btn.setText(self._tr("restore_download_button_text", "🔄 Restore Download")) + self.pause_btn.setEnabled(True) + self.pause_btn.clicked.connect(self.restore_download) + self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download.")) + + # --- START: CORRECTED CANCEL BUTTON LOGIC --- + self.cancel_btn.setText(self._tr("discard_session_button_text", "🗑️ Discard Session")) + self.cancel_btn.setEnabled(True) + self.cancel_btn.clicked.connect(self._clear_session_and_reset_ui) + self.cancel_btn.setToolTip(self._tr("discard_session_tooltip", "Click to discard the interrupted session and reset the UI.")) + + elif is_download_active: + # State: Downloading / Paused + self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) + self.download_btn.setEnabled(False) # Cannot start new download while one is active + + self.pause_btn.setText(self._tr("resume_download_button_text", "▶️ Resume Download") if self.is_paused else self._tr("pause_download_button_text", "⏸️ Pause Download")) + self.pause_btn.setEnabled(True) + self.pause_btn.clicked.connect(self._handle_pause_resume_action) + self.pause_btn.setToolTip(self._tr("resume_download_button_tooltip", "Click to resume the download.") if self.is_paused else self._tr("pause_download_button_tooltip", "Click to pause the download.")) + + self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI")) + self.cancel_btn.setEnabled(True) + self.cancel_btn.clicked.connect(self.cancel_download_button_action) + self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory).")) + else: + # State: Idle (No download, no restore pending) + self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) + self.download_btn.setEnabled(True) + self.download_btn.clicked.connect(self.start_download) + + self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download")) + self.pause_btn.setEnabled(False) # No active download to pause + self.pause_btn.setToolTip(self._tr("pause_download_button_tooltip", "Click to pause the ongoing download process.")) + + self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI")) + self.cancel_btn.setEnabled(False) # No active download to cancel + self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory).")) + + + def _retranslate_main_ui (self ): + """Retranslates static text elements in the main UI.""" + if self .url_label_widget : + self .url_label_widget .setText (self ._tr ("creator_post_url_label","🔗 Kemono Creator/Post URL:")) + if self .download_location_label_widget : + self .download_location_label_widget .setText (self ._tr ("download_location_label","📁 Download Location:")) + if hasattr (self ,'character_label')and self .character_label : + self .character_label .setText (self ._tr ("filter_by_character_label","🎯 Filter by Character(s) (comma-separated):")) + if self .skip_words_label_widget : + self .skip_words_label_widget .setText (self ._tr ("skip_with_words_label","🚫 Skip with Words (comma-separated):")) + if self .remove_from_filename_label_widget : + self .remove_from_filename_label_widget .setText (self ._tr ("remove_words_from_name_label","✂️ Remove Words from name:")) + if hasattr (self ,'radio_all'):self .radio_all .setText (self ._tr ("filter_all_radio","All")) + if hasattr (self ,'radio_images'):self .radio_images .setText (self ._tr ("filter_images_radio","Images/GIFs")) + if hasattr (self ,'radio_videos'):self .radio_videos .setText (self ._tr ("filter_videos_radio","Videos")) + if hasattr (self ,'radio_only_archives'):self .radio_only_archives .setText (self ._tr ("filter_archives_radio","📦 Only Archives")) + if hasattr (self ,'radio_only_links'):self .radio_only_links .setText (self ._tr ("filter_links_radio","🔗 Only Links")) + if hasattr (self ,'radio_only_audio'):self .radio_only_audio .setText (self ._tr ("filter_audio_radio","🎧 Only Audio")) + if hasattr (self ,'favorite_mode_checkbox'):self .favorite_mode_checkbox .setText (self ._tr ("favorite_mode_checkbox_label","⭐ Favorite Mode")) + if hasattr (self ,'dir_button'):self .dir_button .setText (self ._tr ("browse_button_text","Browse...")) + self ._update_char_filter_scope_button_text () + self ._update_skip_scope_button_text () + + if hasattr (self ,'skip_zip_checkbox'):self .skip_zip_checkbox .setText (self ._tr ("skip_zip_checkbox_label","Skip .zip")) + if hasattr (self ,'skip_rar_checkbox'):self .skip_rar_checkbox .setText (self ._tr ("skip_rar_checkbox_label","Skip .rar")) + if hasattr (self ,'download_thumbnails_checkbox'):self .download_thumbnails_checkbox .setText (self ._tr ("download_thumbnails_checkbox_label","Download Thumbnails Only")) + if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setText (self ._tr ("scan_content_images_checkbox_label","Scan Content for Images")) + if hasattr (self ,'compress_images_checkbox'):self .compress_images_checkbox .setText (self ._tr ("compress_images_checkbox_label","Compress to WebP")) + if hasattr (self ,'use_subfolders_checkbox'):self .use_subfolders_checkbox .setText (self ._tr ("separate_folders_checkbox_label","Separate Folders by Name/Title")) + if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .setText (self ._tr ("subfolder_per_post_checkbox_label","Subfolder per Post")) + if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setText (self ._tr ("use_cookie_checkbox_label","Use Cookie")) + if hasattr (self ,'use_multithreading_checkbox'):self .update_multithreading_label (self .thread_count_input .text ()if hasattr (self ,'thread_count_input')else "1") + if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setText (self ._tr ("show_external_links_checkbox_label","Show External Links in Log")) + if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Manga/Comic Mode")) + if hasattr (self ,'thread_count_label'):self .thread_count_label .setText (self ._tr ("threads_label","Threads:")) + + if hasattr (self ,'character_input'): + self .character_input .setToolTip (self ._tr ("character_input_tooltip","Enter character names (comma-separated)...")) + if hasattr (self ,'download_btn'):self .download_btn .setToolTip (self ._tr ("start_download_button_tooltip","Click to start the download or link extraction process with the current settings.")) + + + + + + current_download_is_active =self ._is_download_active ()if hasattr (self ,'_is_download_active')else False + self .set_ui_enabled (not current_download_is_active ) + + if hasattr (self ,'known_chars_label'):self .known_chars_label .setText (self ._tr ("known_chars_label_text","🎭 Known Shows/Characters (for Folder Names):")) + if hasattr (self ,'open_known_txt_button'):self .open_known_txt_button .setText (self ._tr ("open_known_txt_button_text","Open Known.txt"));self .open_known_txt_button .setToolTip (self ._tr ("open_known_txt_button_tooltip","Open the 'Known.txt' file...")) + if hasattr (self ,'add_char_button'):self .add_char_button .setText (self ._tr ("add_char_button_text","➕ Add"));self .add_char_button .setToolTip (self ._tr ("add_char_button_tooltip","Add the name from the input field...")) + if hasattr (self ,'add_to_filter_button'):self .add_to_filter_button .setText (self ._tr ("add_to_filter_button_text","⤵️ Add to Filter"));self .add_to_filter_button .setToolTip (self ._tr ("add_to_filter_button_tooltip","Select names from 'Known Shows/Characters' list...")) + if hasattr (self ,'character_list'): + self .character_list .setToolTip (self ._tr ("known_chars_list_tooltip","This list contains names used for automatic folder creation...")) + if hasattr (self ,'delete_char_button'):self .delete_char_button .setText (self ._tr ("delete_char_button_text","🗑️ Delete Selected"));self .delete_char_button .setToolTip (self ._tr ("delete_char_button_tooltip","Delete the selected name(s)...")) + + if hasattr (self ,'cancel_btn'):self .cancel_btn .setToolTip (self ._tr ("cancel_button_tooltip","Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory).")) + if hasattr (self ,'error_btn'):self .error_btn .setText (self ._tr ("error_button_text","Error"));self .error_btn .setToolTip (self ._tr ("error_button_tooltip","View files skipped due to errors and optionally retry them.")) + if hasattr (self ,'progress_log_label'):self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")) + if hasattr (self ,'reset_button'):self .reset_button .setText (self ._tr ("reset_button_text","🔄 Reset"));self .reset_button .setToolTip (self ._tr ("reset_button_tooltip","Reset all inputs and logs to default state (only when idle).")) + self ._update_multipart_toggle_button_text () + if hasattr (self ,'progress_label')and not self ._is_download_active ():self .progress_label .setText (self ._tr ("progress_idle_text","Progress: Idle")) + if hasattr (self ,'favorite_mode_artists_button'):self .favorite_mode_artists_button .setText (self ._tr ("favorite_artists_button_text","🖼️ Favorite Artists"));self .favorite_mode_artists_button .setToolTip (self ._tr ("favorite_artists_button_tooltip","Browse and download from your favorite artists...")) + if hasattr (self ,'favorite_mode_posts_button'):self .favorite_mode_posts_button .setText (self ._tr ("favorite_posts_button_text","📄 Favorite Posts"));self .favorite_mode_posts_button .setToolTip (self ._tr ("favorite_posts_button_tooltip","Browse and download your favorite posts...")) + self ._update_favorite_scope_button_text () + if hasattr (self ,'page_range_label'):self .page_range_label .setText (self ._tr ("page_range_label_text","Page Range:")) + if hasattr (self ,'start_page_input'): + self .start_page_input .setPlaceholderText (self ._tr ("start_page_input_placeholder","Start")) + self .start_page_input .setToolTip (self ._tr ("start_page_input_tooltip","For creator URLs: Specify the starting page number...")) + if hasattr (self ,'to_label'):self .to_label .setText (self ._tr ("page_range_to_label_text","to")) + if hasattr (self ,'end_page_input'): + self .end_page_input .setPlaceholderText (self ._tr ("end_page_input_placeholder","End")) + self .end_page_input .setToolTip (self ._tr ("end_page_input_tooltip","For creator URLs: Specify the ending page number...")) + if hasattr (self ,'fav_mode_active_label'): + self .fav_mode_active_label .setText (self ._tr ("fav_mode_active_label_text","⭐ Favorite Mode is active...")) + if hasattr (self ,'cookie_browse_button'): + self .cookie_browse_button .setToolTip (self ._tr ("cookie_browse_button_tooltip","Browse for a cookie file...")) + self ._update_manga_filename_style_button_text () + if hasattr (self ,'export_links_button'):self .export_links_button .setText (self ._tr ("export_links_button_text","Export Links")) + if hasattr (self ,'download_extracted_links_button'):self .download_extracted_links_button .setText (self ._tr ("download_extracted_links_button_text","Download")) + self ._update_log_display_mode_button_text () + + + if hasattr (self ,'radio_all'):self .radio_all .setToolTip (self ._tr ("radio_all_tooltip","Download all file types found in posts.")) + if hasattr (self ,'radio_images'):self .radio_images .setToolTip (self ._tr ("radio_images_tooltip","Download only common image formats (JPG, PNG, GIF, WEBP, etc.).")) + if hasattr (self ,'radio_videos'):self .radio_videos .setToolTip (self ._tr ("radio_videos_tooltip","Download only common video formats (MP4, MKV, WEBM, MOV, etc.).")) + if hasattr (self ,'radio_only_archives'):self .radio_only_archives .setToolTip (self ._tr ("radio_only_archives_tooltip","Exclusively download .zip and .rar files. Other file-specific options are disabled.")) + if hasattr (self ,'radio_only_audio'):self .radio_only_audio .setToolTip (self ._tr ("radio_only_audio_tooltip","Download only common audio formats (MP3, WAV, FLAC, etc.).")) + if hasattr (self ,'radio_only_links'):self .radio_only_links .setToolTip (self ._tr ("radio_only_links_tooltip","Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled.")) + + + if hasattr (self ,'use_subfolders_checkbox'):self .use_subfolders_checkbox .setToolTip (self ._tr ("use_subfolders_checkbox_tooltip","Create subfolders based on 'Filter by Character(s)' input...")) + if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .setToolTip (self ._tr ("use_subfolder_per_post_checkbox_tooltip","Creates a subfolder for each post...")) + if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setToolTip (self ._tr ("use_cookie_checkbox_tooltip","If checked, will attempt to use cookies...")) + if hasattr (self ,'use_multithreading_checkbox'):self .use_multithreading_checkbox .setToolTip (self ._tr ("use_multithreading_checkbox_tooltip","Enables concurrent operations...")) + if hasattr (self ,'thread_count_input'):self .thread_count_input .setToolTip (self ._tr ("thread_count_input_tooltip","Number of concurrent operations...")) + if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setToolTip (self ._tr ("external_links_checkbox_tooltip","If checked, a secondary log panel appears...")) + if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setToolTip (self ._tr ("manga_mode_checkbox_tooltip","Downloads posts from oldest to newest...")) + + if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setToolTip (self ._tr ("scan_content_images_checkbox_tooltip",self ._original_scan_content_tooltip )) + if hasattr (self ,'download_thumbnails_checkbox'):self .download_thumbnails_checkbox .setToolTip (self ._tr ("download_thumbnails_checkbox_tooltip","Downloads small preview images...")) + if hasattr (self ,'skip_words_input'): + self .skip_words_input .setToolTip (self ._tr ("skip_words_input_tooltip", + ("Enter words, comma-separated, to skip downloading certain content (e.g., WIP, sketch, preview).\n\n" + "The 'Scope: [Type]' button next to this input cycles how this filter applies:\n" + "- Scope: Files: Skips individual files if their names contain any of these words.\n" + "- Scope: Posts: Skips entire posts if their titles contain any of these words.\n" + "- Scope: Both: Applies both (post title first, then individual files if post title is okay)."))) + if hasattr (self ,'remove_from_filename_input'): + self .remove_from_filename_input .setToolTip (self ._tr ("remove_words_input_tooltip", + ("Enter words, comma-separated, to remove from downloaded filenames (case-insensitive).\n" + "Useful for cleaning up common prefixes/suffixes.\nExample: patreon, kemono, [HD], _final"))) + + if hasattr (self ,'link_input'): + self .link_input .setPlaceholderText (self ._tr ("link_input_placeholder_text","e.g., https://kemono.su/patreon/user/12345 or .../post/98765")) + self .link_input .setToolTip (self ._tr ("link_input_tooltip_text","Enter the full URL...")) + if hasattr (self ,'dir_input'): + self .dir_input .setPlaceholderText (self ._tr ("dir_input_placeholder_text","Select folder where downloads will be saved")) + self .dir_input .setToolTip (self ._tr ("dir_input_tooltip_text","Enter or browse to the main folder...")) + if hasattr (self ,'character_input'): + self .character_input .setPlaceholderText (self ._tr ("character_input_placeholder_text","e.g., Tifa, Aerith, (Cloud, Zack)")) + if hasattr (self ,'custom_folder_input'): + self .custom_folder_input .setPlaceholderText (self ._tr ("custom_folder_input_placeholder_text","Optional: Save this post to specific folder")) + self .custom_folder_input .setToolTip (self ._tr ("custom_folder_input_tooltip_text","If downloading a single post URL...")) + if hasattr (self ,'skip_words_input'): + self .skip_words_input .setPlaceholderText (self ._tr ("skip_words_input_placeholder_text","e.g., WM, WIP, sketch, preview")) + if hasattr (self ,'remove_from_filename_input'): + self .remove_from_filename_input .setPlaceholderText (self ._tr ("remove_from_filename_input_placeholder_text","e.g., patreon, HD")) + self ._update_cookie_input_placeholders_and_tooltips () + if hasattr (self ,'character_search_input'): + self .character_search_input .setPlaceholderText (self ._tr ("character_search_input_placeholder_text","Search characters...")) + self .character_search_input .setToolTip (self ._tr ("character_search_input_tooltip_text","Type here to filter the list...")) + if hasattr (self ,'new_char_input'): + self .new_char_input .setPlaceholderText (self ._tr ("new_char_input_placeholder_text","Add new show/character name")) + self .new_char_input .setToolTip (self ._tr ("new_char_input_tooltip_text","Enter a new show, game, or character name...")) + if hasattr (self ,'link_search_input'): + self .link_search_input .setPlaceholderText (self ._tr ("link_search_input_placeholder_text","Search Links...")) + self .link_search_input .setToolTip (self ._tr ("link_search_input_tooltip_text","When in 'Only Links' mode...")) + if hasattr (self ,'manga_date_prefix_input'): + self .manga_date_prefix_input .setPlaceholderText (self ._tr ("manga_date_prefix_input_placeholder_text","Prefix for Manga Filenames")) + self .manga_date_prefix_input .setToolTip (self ._tr ("manga_date_prefix_input_tooltip_text","Optional prefix for 'Date Based'...")) + if hasattr (self ,'empty_popup_button'):self .empty_popup_button .setToolTip (self ._tr ("empty_popup_button_tooltip_text","Open Creator Selection...")) + if hasattr (self ,'known_names_help_button'):self .known_names_help_button .setToolTip (self ._tr ("known_names_help_button_tooltip_text","Open the application feature guide.")) + if hasattr (self ,'future_settings_button'):self .future_settings_button .setToolTip (self ._tr ("future_settings_button_tooltip_text","Open application settings...")) + if hasattr (self ,'link_search_button'):self .link_search_button .setToolTip (self ._tr ("link_search_button_tooltip_text","Filter displayed links")) + def apply_theme (self ,theme_name ,initial_load =False ): + self .current_theme =theme_name + if not initial_load : + self .settings .setValue (THEME_KEY ,theme_name ) + self .settings .sync () + + if theme_name =="dark": + self .setStyleSheet (self .get_dark_theme ()) + if not initial_load : + self .log_signal .emit ("🎨 Switched to Dark Mode.") + else : + self .setStyleSheet ("") + if not initial_load : + self .log_signal .emit ("🎨 Switched to Light Mode.") + self .update () + + def _get_tooltip_for_character_input (self ): + return ( + self ._tr ("character_input_tooltip","Default tooltip if translation fails.") + ) + def _connect_signals (self ): + self .actual_gui_signals .progress_signal .connect (self .handle_main_log ) + self .actual_gui_signals .file_progress_signal .connect (self .update_file_progress_display ) + self .actual_gui_signals .missed_character_post_signal .connect (self .handle_missed_character_post ) + self .actual_gui_signals .external_link_signal .connect (self .handle_external_link_signal ) + self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded ) + self .actual_gui_signals .file_download_status_signal .connect (lambda status :None ) + + if hasattr (self ,'character_input'): + self .character_input .textChanged .connect (self ._on_character_input_changed_live ) + if hasattr (self ,'use_cookie_checkbox'): + self .use_cookie_checkbox .toggled .connect (self ._update_cookie_input_visibility ) + if hasattr (self ,'link_input'): + self .link_input .textChanged .connect (self ._sync_queue_with_link_input ) + if hasattr (self ,'cookie_browse_button'): + self .cookie_browse_button .clicked .connect (self ._browse_cookie_file ) + if hasattr (self ,'cookie_text_input'): + self .cookie_text_input .textChanged .connect (self ._handle_cookie_text_manual_change ) + if hasattr (self ,'download_thumbnails_checkbox'): + self .download_thumbnails_checkbox .toggled .connect (self ._handle_thumbnail_mode_change ) + self .gui_update_timer .timeout .connect (self ._process_worker_queue ) + self .gui_update_timer .start (100 ) + 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 .post_processed_for_history_signal .connect (self ._add_to_history_candidates ) + self .finished_signal .connect (self .download_finished ) + if hasattr (self ,'character_search_input'):self .character_search_input .textChanged .connect (self .filter_character_list ) + if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .toggled .connect (self .update_external_links_setting ) + if hasattr (self ,'thread_count_input'):self .thread_count_input .textChanged .connect (self .update_multithreading_label ) + if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .toggled .connect (self .update_ui_for_subfolders ) + if hasattr (self ,'use_multithreading_checkbox'):self .use_multithreading_checkbox .toggled .connect (self ._handle_multithreading_toggle ) + + if hasattr (self ,'radio_group')and self .radio_group : + self .radio_group .buttonToggled .connect (self ._handle_filter_mode_change ) + + if self .reset_button :self .reset_button .clicked .connect (self .reset_application_state ) + if self .log_verbosity_toggle_button :self .log_verbosity_toggle_button .clicked .connect (self .toggle_active_log_view ) + + if self .link_search_button :self .link_search_button .clicked .connect (self ._filter_links_log ) + if self .link_search_input : + self .link_search_input .returnPressed .connect (self ._filter_links_log ) + self .link_search_input .textChanged .connect (self ._filter_links_log ) + if self .export_links_button :self .export_links_button .clicked .connect (self ._export_links_to_file ) + + if self .manga_mode_checkbox :self .manga_mode_checkbox .toggled .connect (self .update_ui_for_manga_mode ) + + + if hasattr (self ,'download_extracted_links_button'): + self .download_extracted_links_button .clicked .connect (self ._show_download_extracted_links_dialog ) + + if hasattr (self ,'log_display_mode_toggle_button'): + self .log_display_mode_toggle_button .clicked .connect (self ._toggle_log_display_mode ) + + if self .manga_rename_toggle_button :self .manga_rename_toggle_button .clicked .connect (self ._toggle_manga_filename_style ) + + if hasattr (self ,'link_input'): + self .link_input .textChanged .connect (lambda :self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )) + + if self .skip_scope_toggle_button : + self .skip_scope_toggle_button .clicked .connect (self ._cycle_skip_scope ) + + if self .char_filter_scope_toggle_button : + self .char_filter_scope_toggle_button .clicked .connect (self ._cycle_char_filter_scope ) + + if hasattr (self ,'multipart_toggle_button'):self .multipart_toggle_button .clicked .connect (self ._toggle_multipart_mode ) + + + if hasattr (self ,'favorite_mode_checkbox'): + self .favorite_mode_checkbox .toggled .connect (self ._handle_favorite_mode_toggle ) + + if hasattr (self ,'open_known_txt_button'): + self .open_known_txt_button .clicked .connect (self ._open_known_txt_file ) + + if hasattr (self ,'add_to_filter_button'): + self .add_to_filter_button .clicked .connect (self ._show_add_to_filter_dialog ) + if hasattr (self ,'favorite_mode_artists_button'): + self .favorite_mode_artists_button .clicked .connect (self ._show_favorite_artists_dialog ) + if hasattr (self ,'favorite_mode_posts_button'): + self .favorite_mode_posts_button .clicked .connect (self ._show_favorite_posts_dialog ) + if hasattr (self ,'favorite_scope_toggle_button'): + self .favorite_scope_toggle_button .clicked .connect (self ._cycle_favorite_scope ) + if hasattr (self ,'history_button'): + self .history_button .clicked .connect (self ._show_download_history_dialog ) + if hasattr (self ,'error_btn'): + self .error_btn .clicked .connect (self ._show_error_files_dialog ) + + def _on_character_input_changed_live (self ,text ): + """ + Called when the character input field text changes. + If a download is active (running or paused), this updates the dynamic filter holder. + """ + if self ._is_download_active (): + QCoreApplication .processEvents () + raw_character_filters_text =self .character_input .text ().strip () + parsed_filters =self ._parse_character_filters (raw_character_filters_text ) + + self .dynamic_character_filter_holder .set_filters (parsed_filters ) + + def _parse_character_filters (self ,raw_text ): + """Helper to parse character filter string into list of objects.""" + parsed_character_filter_objects =[] + if raw_text : + raw_parts =[] + current_part_buffer ="" + in_group_parsing =False + for char_token in raw_text : + if char_token =='('and not in_group_parsing : + in_group_parsing =True + current_part_buffer +=char_token + elif char_token ==')'and in_group_parsing : + in_group_parsing =False + current_part_buffer +=char_token + elif char_token ==','and not in_group_parsing : + if current_part_buffer .strip ():raw_parts .append (current_part_buffer .strip ()) + current_part_buffer ="" + else : + current_part_buffer +=char_token + if current_part_buffer .strip ():raw_parts .append (current_part_buffer .strip ()) + + for part_str in raw_parts : + part_str =part_str .strip () + if not part_str :continue + + is_tilde_group =part_str .startswith ("(")and part_str .endswith (")~") + is_standard_group_for_splitting =part_str .startswith ("(")and part_str .endswith (")")and not is_tilde_group + + if is_tilde_group : + group_content_str =part_str [1 :-2 ].strip () + aliases_in_group =[alias .strip ()for alias in group_content_str .split (',')if alias .strip ()] + if aliases_in_group : + group_folder_name =" ".join (aliases_in_group ) + parsed_character_filter_objects .append ({"name":group_folder_name ,"is_group":True ,"aliases":aliases_in_group }) + elif is_standard_group_for_splitting : + group_content_str =part_str [1 :-1 ].strip () + aliases_in_group =[alias .strip ()for alias in group_content_str .split (',')if alias .strip ()] + if aliases_in_group : + group_folder_name =" ".join (aliases_in_group ) + parsed_character_filter_objects .append ({ + "name":group_folder_name , + "is_group":True , + "aliases":aliases_in_group , + "components_are_distinct_for_known_txt":True + }) + else : + parsed_character_filter_objects .append ({"name":part_str ,"is_group":False ,"aliases":[part_str ],"components_are_distinct_for_known_txt":False }) + return parsed_character_filter_objects + + def _process_worker_queue (self ): + """Processes messages from the worker queue and emits Qt signals from the GUI thread.""" + while not self .worker_to_gui_queue .empty (): + try : + item =self .worker_to_gui_queue .get_nowait () + signal_type =item .get ('type') + payload =item .get ('payload',tuple ()) + + if signal_type =='progress': + self .actual_gui_signals .progress_signal .emit (*payload ) + elif signal_type =='file_download_status': + self .actual_gui_signals .file_download_status_signal .emit (*payload ) + elif signal_type =='external_link': + self .actual_gui_signals .external_link_signal .emit (*payload ) + elif signal_type =='file_progress': + self .actual_gui_signals .file_progress_signal .emit (*payload ) + elif signal_type =='missed_character_post': + self .actual_gui_signals .missed_character_post_signal .emit (*payload ) + elif signal_type =='file_successfully_downloaded': + self ._handle_actual_file_downloaded (payload [0 ]if payload else {}) + elif signal_type =='file_successfully_downloaded': + self ._handle_file_successfully_downloaded (payload [0 ]) + else : + self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }") + self .worker_to_gui_queue .task_done () + except queue .Empty : + break + except Exception as e : + self .log_signal .emit (f"❌ Error processing worker queue: {e }") + + def load_known_names_from_util (self ): + global KNOWN_NAMES + if os .path .exists (self .config_file ): + parsed_known_objects =[] + try : + with open (self .config_file ,'r',encoding ='utf-8')as f : + for line_num ,line in enumerate (f ,1 ): + line =line .strip () + if not line :continue + + if line .startswith ("(")and line .endswith (")"): + content =line [1 :-1 ].strip () + parts =[p .strip ()for p in content .split (',')if p .strip ()] + if parts : + folder_name_raw =content .replace (',',' ') + folder_name_cleaned =clean_folder_name (folder_name_raw ) + + unique_aliases_set ={p for p in parts } + final_aliases_list =sorted (list (unique_aliases_set ),key =str .lower ) + + if not folder_name_cleaned : + if hasattr (self ,'log_signal'):self .log_signal .emit (f"⚠️ Group resulted in empty folder name after cleaning in Known.txt on line {line_num }: '{line }'. Skipping entry.") + continue + + parsed_known_objects .append ({ + "name":folder_name_cleaned , + "is_group":True , + "aliases":final_aliases_list + }) + else : + if hasattr (self ,'log_signal'):self .log_signal .emit (f"⚠️ Empty group found in Known.txt on line {line_num }: '{line }'") + else : + parsed_known_objects .append ({ + "name":line , + "is_group":False , + "aliases":[line ] + }) + parsed_known_objects .sort (key =lambda x :x ["name"].lower ()) + KNOWN_NAMES [:]=parsed_known_objects + log_msg =f"ℹ️ Loaded {len (KNOWN_NAMES )} known entries 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 : + self .character_input .setToolTip ("Names, comma-separated. Group aliases: (alias1, alias2, alias3) becomes folder name 'alias1 alias2 alias3' (after cleaning).\nAll names in the group are used as aliases for matching.\nE.g., yor, (Boa, Hancock, Snake Princess)") + log_msg =f"ℹ️ Config file '{self .config_file }' not found. It will be created on save." + KNOWN_NAMES [:]=[] + + if hasattr (self ,'log_signal'):self .log_signal .emit (log_msg ) + + if hasattr (self ,'character_list'): + self .character_list .clear () + if not KNOWN_NAMES : + self .log_signal .emit ("ℹ️ 'Known.txt' is empty or was not found. No default entries will be added.") + + self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ]) + + def save_known_names(self): + """ + Saves the current list of known names (KNOWN_NAMES) to the config file. + This version includes a fix to ensure the destination directory exists + before attempting to write the file, preventing crashes in new installations. + """ + global KNOWN_NAMES + try: + # --- FIX STARTS HERE --- + # Get the directory path from the full file path. + config_dir = os.path.dirname(self.config_file) + # Create the directory if it doesn't exist. 'exist_ok=True' prevents + # an error if the directory is already there. + os.makedirs(config_dir, exist_ok=True) + # --- FIX ENDS HERE --- + + with open(self.config_file, 'w', encoding='utf-8') as f: + for entry in KNOWN_NAMES: + if entry["is_group"]: + # For groups, write the aliases in a sorted, comma-separated format inside parentheses. + f.write(f"({', '.join(sorted(entry['aliases'], key=str.lower))})\n") + else: + # For single entries, write the name on its own line. + f.write(entry["name"] + '\n') + + if hasattr(self, 'log_signal'): + self.log_signal.emit(f"💾 Saved {len(KNOWN_NAMES)} known entries to {self.config_file}") + + except Exception as e: + # If any error occurs during saving, log it and show a warning popup. + log_msg = f"❌ Error saving config '{self.config_file}': {e}" + if hasattr(self, 'log_signal'): + 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 () + self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style ) + self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting ) + self .settings .setValue (COOKIE_TEXT_KEY ,self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "") + self .settings .setValue (SCAN_CONTENT_IMAGES_KEY ,self .scan_content_images_checkbox .isChecked ()if hasattr (self ,'scan_content_images_checkbox')else False ) + self .settings .setValue (USE_COOKIE_KEY ,self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ) + self .settings .setValue (THEME_KEY ,self .current_theme ) + self .settings .setValue (LANGUAGE_KEY ,self .current_selected_language ) + self .settings .sync () + self ._save_persistent_history () + + should_exit =True + is_downloading =self ._is_download_active () + + 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 .cancellation_event .set () + if self .download_thread and self .download_thread .isRunning (): + self .download_thread .requestInterruption () + self .log_signal .emit (" Signaled single download thread to interrupt.") + if self .download_thread and self .download_thread .isRunning (): + self .log_signal .emit (" Waiting for single download thread to finish...") + self .download_thread .wait (3000 ) + if self .download_thread .isRunning (): + self .log_signal .emit (" ⚠️ Single download thread did not terminate gracefully.") + + if self .thread_pool : + self .log_signal .emit (" Shutting down thread pool (waiting for completion)...") + self .thread_pool .shutdown (wait =True ,cancel_futures =True ) + self .log_signal .emit (" Thread pool shutdown complete.") + self .thread_pool =None + self .log_signal .emit (" Cancellation for exit complete.") + else : + should_exit =False + self .log_signal .emit ("ℹ️ Application exit cancelled.") + event .ignore () + return + + if should_exit : + self .log_signal .emit ("ℹ️ Application closing.") + 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 _request_restart_application (self ): + self .log_signal .emit ("🔄 Application restart requested by user for language change.") + self ._restart_pending =True + self .close () + + def _do_actual_restart (self ): + try : + self .log_signal .emit (" Performing application restart...") + python_executable =sys .executable + script_args =sys .argv + + + if getattr (sys ,'frozen',False ): + + + + QProcess .startDetached (python_executable ,script_args [1 :]) + else : + + + QProcess .startDetached (python_executable ,script_args ) + + QCoreApplication .instance ().quit () + except Exception as e : + self .log_signal .emit (f"❌ CRITICAL: Failed to start new application instance: {e }") + QMessageBox .critical (self ,"Restart Failed", + f"Could not automatically restart the application: {e }\n\nPlease restart it manually.") + + +def init_ui(self): + self.main_splitter = QSplitter(Qt.Horizontal) + + # --- Use a scroll area for the left panel for consistency --- + left_scroll_area = QScrollArea() + left_scroll_area.setWidgetResizable(True) + left_scroll_area.setFrameShape(QFrame.NoFrame) + + left_panel_widget = QWidget() + left_layout = QVBoxLayout(left_panel_widget) + left_scroll_area.setWidget(left_panel_widget) + + right_panel_widget = QWidget() + right_layout = QVBoxLayout(right_panel_widget) + + left_layout.setContentsMargins(10, 10, 10, 10) + right_layout.setContentsMargins(10, 10, 10, 10) + self.apply_theme(self.current_theme, initial_load=True) + + # --- URL and Page Range --- + self.url_input_widget = QWidget() + url_input_layout = QHBoxLayout(self.url_input_widget) + url_input_layout.setContentsMargins(0, 0, 0, 0) + self.url_label_widget = QLabel() + url_input_layout.addWidget(self.url_label_widget) + 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) # Connects the custom folder logic + url_input_layout.addWidget(self.link_input, 1) + self.empty_popup_button = QPushButton("🎨") + self.empty_popup_button.setStyleSheet("padding: 4px 6px;") + self.empty_popup_button.clicked.connect(self._show_empty_popup) + url_input_layout.addWidget(self.empty_popup_button) + self.page_range_label = QLabel(self._tr("page_range_label_text", "Page Range:")) + self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") + url_input_layout.addWidget(self.page_range_label) + self.start_page_input = QLineEdit() + self.start_page_input.setPlaceholderText(self._tr("start_page_input_placeholder", "Start")) + self.start_page_input.setFixedWidth(50) + self.start_page_input.setValidator(QIntValidator(1, 99999)) + url_input_layout.addWidget(self.start_page_input) + self.to_label = QLabel(self._tr("page_range_to_label_text", "to")) + url_input_layout.addWidget(self.to_label) + self.end_page_input = QLineEdit() + self.end_page_input.setPlaceholderText(self._tr("end_page_input_placeholder", "End")) + self.end_page_input.setFixedWidth(50) + self.end_page_input.setToolTip(self._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number...")) + self.end_page_input.setValidator(QIntValidator(1, 99999)) + url_input_layout.addWidget(self.end_page_input) + self.url_placeholder_widget = QWidget() + placeholder_layout = QHBoxLayout(self.url_placeholder_widget) + placeholder_layout.setContentsMargins(0, 0, 0, 0) + self.fav_mode_active_label = QLabel(self._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active...")) + self.fav_mode_active_label.setAlignment(Qt.AlignCenter) + placeholder_layout.addWidget(self.fav_mode_active_label) + self.url_or_placeholder_stack = QStackedWidget() + self.url_or_placeholder_stack.addWidget(self.url_input_widget) + self.url_or_placeholder_stack.addWidget(self.url_placeholder_widget) + left_layout.addWidget(self.url_or_placeholder_stack) + + # --- Download Location --- + self.download_location_label_widget = QLabel() + left_layout.addWidget(self.download_location_label_widget) + dir_layout = QHBoxLayout() + self.dir_input = QLineEdit() + self.dir_input.setPlaceholderText("Select folder where downloads will be saved") + self.dir_button = QPushButton("Browse...") + self.dir_button.setStyleSheet("padding: 4px 10px;") + self.dir_button.clicked.connect(self.browse_directory) + dir_layout.addWidget(self.dir_input, 1) + dir_layout.addWidget(self.dir_button) + left_layout.addLayout(dir_layout) + + # --- Filters and Custom Folder Container (from old layout) --- + self.filters_and_custom_folder_container_widget = QWidget() + filters_and_custom_folder_layout = QHBoxLayout(self.filters_and_custom_folder_container_widget) + filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0) + filters_and_custom_folder_layout.setSpacing(10) + self.character_filter_widget = QWidget() + character_filter_v_layout = QVBoxLayout(self.character_filter_widget) + character_filter_v_layout.setContentsMargins(0, 0, 0, 0) + character_filter_v_layout.setSpacing(2) + self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):") + character_filter_v_layout.addWidget(self.character_label) + char_input_and_button_layout = QHBoxLayout() + char_input_and_button_layout.setContentsMargins(0, 0, 0, 0) + char_input_and_button_layout.setSpacing(10) + self.character_input = QLineEdit() + self.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)") + char_input_and_button_layout.addWidget(self.character_input, 3) + self.char_filter_scope_toggle_button = QPushButton() + self._update_char_filter_scope_button_text() + char_input_and_button_layout.addWidget(self.char_filter_scope_toggle_button, 1) + character_filter_v_layout.addLayout(char_input_and_button_layout) + + # --- Custom Folder Widget Definition --- + self.custom_folder_widget = QWidget() + custom_folder_v_layout = QVBoxLayout(self.custom_folder_widget) + custom_folder_v_layout.setContentsMargins(0, 0, 0, 0) + custom_folder_v_layout.setSpacing(2) + 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_v_layout.addWidget(self.custom_folder_label) + custom_folder_v_layout.addWidget(self.custom_folder_input) + self.custom_folder_widget.setVisible(False) + + filters_and_custom_folder_layout.addWidget(self.character_filter_widget, 1) + filters_and_custom_folder_layout.addWidget(self.custom_folder_widget, 1) + left_layout.addWidget(self.filters_and_custom_folder_container_widget) + + # --- Word Manipulation Container --- + word_manipulation_container_widget = QWidget() + word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget) + word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0) + word_manipulation_outer_layout.setSpacing(15) + skip_words_widget = QWidget() + skip_words_vertical_layout = QVBoxLayout(skip_words_widget) + skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0) + skip_words_vertical_layout.setSpacing(2) + self.skip_words_label_widget = QLabel() + skip_words_vertical_layout.addWidget(self.skip_words_label_widget) + skip_input_and_button_layout = QHBoxLayout() + skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0) + skip_input_and_button_layout.setSpacing(10) + self.skip_words_input = QLineEdit() + self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview") + skip_input_and_button_layout.addWidget(self.skip_words_input, 1) + self.skip_scope_toggle_button = QPushButton() + self._update_skip_scope_button_text() + skip_input_and_button_layout.addWidget(self.skip_scope_toggle_button, 0) + skip_words_vertical_layout.addLayout(skip_input_and_button_layout) + word_manipulation_outer_layout.addWidget(skip_words_widget, 7) + remove_words_widget = QWidget() + remove_words_vertical_layout = QVBoxLayout(remove_words_widget) + remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0) + remove_words_vertical_layout.setSpacing(2) + self.remove_from_filename_label_widget = QLabel() + remove_words_vertical_layout.addWidget(self.remove_from_filename_label_widget) + self.remove_from_filename_input = QLineEdit() + self.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD") + remove_words_vertical_layout.addWidget(self.remove_from_filename_input) + word_manipulation_outer_layout.addWidget(remove_words_widget, 3) + left_layout.addWidget(word_manipulation_container_widget) + + # --- File Filter Layout --- + file_filter_layout = QVBoxLayout() + file_filter_layout.setContentsMargins(0, 10, 0, 0) + file_filter_layout.addWidget(QLabel("Filter Files:")) + radio_button_layout = QHBoxLayout() + radio_button_layout.setSpacing(10) + self.radio_group = QButtonGroup(self) + self.radio_all = QRadioButton("All") + self.radio_images = QRadioButton("Images/GIFs") + self.radio_videos = QRadioButton("Videos") + self.radio_only_archives = QRadioButton("📦 Only Archives") + self.radio_only_audio = QRadioButton("🎧 Only Audio") + self.radio_only_links = QRadioButton("🔗 Only Links") + self.radio_all.setChecked(True) + for btn in [self.radio_all, self.radio_images, self.radio_videos, self.radio_only_archives, self.radio_only_audio, self.radio_only_links]: + self.radio_group.addButton(btn) + radio_button_layout.addWidget(btn) + self.favorite_mode_checkbox = QCheckBox() + self.favorite_mode_checkbox.setChecked(False) + radio_button_layout.addWidget(self.favorite_mode_checkbox) + radio_button_layout.addStretch(1) + file_filter_layout.addLayout(radio_button_layout) + left_layout.addLayout(file_filter_layout) + + # --- Checkboxes Group --- + checkboxes_group_layout = QVBoxLayout() + checkboxes_group_layout.setSpacing(10) + row1_layout = QHBoxLayout() + 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") + row1_layout.addWidget(self.download_thumbnails_checkbox) + self.scan_content_images_checkbox = QCheckBox("Scan Content for Images") + self.scan_content_images_checkbox.setChecked(self.scan_content_images_setting) + row1_layout.addWidget(self.scan_content_images_checkbox) + self.compress_images_checkbox = QCheckBox("Compress to WebP") + self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).") + row1_layout.addWidget(self.compress_images_checkbox) + self.keep_duplicates_checkbox = QCheckBox("Keep Duplicates") + self.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.") + row1_layout.addWidget(self.keep_duplicates_checkbox) + row1_layout.addStretch(1) + checkboxes_group_layout.addLayout(row1_layout) + + # --- Advanced Settings --- + advanced_settings_label = QLabel("⚙️ Advanced Settings:") + checkboxes_group_layout.addWidget(advanced_settings_label) + advanced_row1_layout = QHBoxLayout() + 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.toggled.connect(self.update_ui_for_subfolders) + advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox) + self.date_prefix_checkbox = QCheckBox("Date Prefix") + self.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") + advanced_row1_layout.addWidget(self.date_prefix_checkbox) + self.use_cookie_checkbox = QCheckBox("Use Cookie") + self.use_cookie_checkbox.setChecked(self.use_cookie_setting) + self.cookie_text_input = QLineEdit() + self.cookie_text_input.setPlaceholderText("if no Select cookies.txt)") + self.cookie_text_input.setText(self.cookie_text_setting) + advanced_row1_layout.addWidget(self.use_cookie_checkbox) + advanced_row1_layout.addWidget(self.cookie_text_input, 2) + self.cookie_browse_button = QPushButton("Browse...") + self.cookie_browse_button.setFixedWidth(80) + self.cookie_browse_button.setStyleSheet("padding: 4px 8px;") + advanced_row1_layout.addWidget(self.cookie_browse_button) + advanced_row1_layout.addStretch(1) + checkboxes_group_layout.addLayout(advanced_row1_layout) + advanced_row2_layout = QHBoxLayout() + advanced_row2_layout.setSpacing(10) + multithreading_layout = QHBoxLayout() + multithreading_layout.setContentsMargins(0, 0, 0, 0) + self.use_multithreading_checkbox = QCheckBox("Use Multithreading") + self.use_multithreading_checkbox.setChecked(True) + 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("4") + self.thread_count_input.setFixedWidth(40) + self.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS)) + multithreading_layout.addWidget(self.thread_count_input) + advanced_row2_layout.addLayout(multithreading_layout) + self.external_links_checkbox = QCheckBox("Show External Links in Log") + advanced_row2_layout.addWidget(self.external_links_checkbox) + self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode") + 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) + + # --- Action Buttons --- + self.standard_action_buttons_widget = QWidget() + btn_layout = QHBoxLayout(self.standard_action_buttons_widget) + btn_layout.setContentsMargins(0, 10, 0, 0) + btn_layout.setSpacing(10) + self.download_btn = QPushButton("⬇️ Start Download") + self.download_btn.setStyleSheet("padding: 4px 12px; font-weight: bold;") + self.download_btn.clicked.connect(self.start_download) + self.pause_btn = QPushButton("⏸️ Pause Download") + self.pause_btn.setEnabled(False) + self.pause_btn.setStyleSheet("padding: 4px 12px;") + self.pause_btn.clicked.connect(self._handle_pause_resume_action) + self.cancel_btn = QPushButton("❌ Cancel & Reset UI") + self.cancel_btn.setEnabled(False) + self.cancel_btn.setStyleSheet("padding: 4px 12px;") + self.cancel_btn.clicked.connect(self.cancel_download_button_action) + self.error_btn = QPushButton("Error") + self.error_btn.setToolTip("View files skipped due to errors and optionally retry them.") + self.error_btn.setStyleSheet("padding: 4px 8px;") + self.error_btn.setEnabled(True) + btn_layout.addWidget(self.download_btn) + btn_layout.addWidget(self.pause_btn) + btn_layout.addWidget(self.cancel_btn) + btn_layout.addWidget(self.error_btn) + self.favorite_action_buttons_widget = QWidget() + favorite_buttons_layout = QHBoxLayout(self.favorite_action_buttons_widget) + self.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists") + self.favorite_mode_posts_button = QPushButton("📄 Favorite Posts") + self.favorite_scope_toggle_button = QPushButton() + favorite_buttons_layout.addWidget(self.favorite_mode_artists_button) + favorite_buttons_layout.addWidget(self.favorite_mode_posts_button) + favorite_buttons_layout.addWidget(self.favorite_scope_toggle_button) + self.bottom_action_buttons_stack = QStackedWidget() + self.bottom_action_buttons_stack.addWidget(self.standard_action_buttons_widget) + self.bottom_action_buttons_stack.addWidget(self.favorite_action_buttons_widget) + left_layout.addWidget(self.bottom_action_buttons_stack) + left_layout.addSpacing(10) + + # --- Known Names Layout --- + known_chars_label_layout = QHBoxLayout() + known_chars_label_layout.setSpacing(10) + self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") + known_chars_label_layout.addWidget(self.known_chars_label) + self.open_known_txt_button = QPushButton("Open Known.txt") + self.open_known_txt_button.setStyleSheet("padding: 4px 8px;") + self.open_known_txt_button.setFixedWidth(120) + known_chars_label_layout.addWidget(self.open_known_txt_button) + self.character_search_input = QLineEdit() + self.character_search_input.setPlaceholderText("Search characters...") + known_chars_label_layout.addWidget(self.character_search_input, 1) + left_layout.addLayout(known_chars_label_layout) + self.character_list = QListWidget() + self.character_list.setSelectionMode(QListWidget.ExtendedSelection) + self.character_list.setMaximumHeight(150) # Set smaller height + left_layout.addWidget(self.character_list, 1) + char_manage_layout = QHBoxLayout() + char_manage_layout.setSpacing(10) + self.new_char_input = QLineEdit() + self.new_char_input.setPlaceholderText("Add new show/character name") + self.new_char_input.setStyleSheet("padding: 3px 5px;") + self.add_char_button = QPushButton("➕ Add") + self.add_char_button.setStyleSheet("padding: 4px 10px;") + self.add_to_filter_button = QPushButton("⤵️ Add to Filter") + self.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.") + self.add_to_filter_button.setStyleSheet("padding: 4px 10px;") + self.delete_char_button = QPushButton("🗑️ Delete Selected") + self.delete_char_button.setToolTip("Delete the selected name(s)...") + self.delete_char_button.setStyleSheet("padding: 4px 10px;") + self.add_char_button.clicked.connect(self._handle_ui_add_new_character) + self.new_char_input.returnPressed.connect(self.add_char_button.click) + self.delete_char_button.clicked.connect(self.delete_selected_character) + char_manage_layout.addWidget(self.new_char_input, 2) + char_manage_layout.addWidget(self.add_char_button, 0) + self.known_names_help_button = QPushButton("?") + self.known_names_help_button.setFixedWidth(35) + self.known_names_help_button.setStyleSheet("padding: 4px 6px;") + self.known_names_help_button.clicked.connect(self._show_feature_guide) + self.history_button = QPushButton("📜") + self.history_button.setFixedWidth(35) + self.history_button.setStyleSheet("padding: 4px 6px;") + self.history_button.setToolTip(self._tr("history_button_tooltip_text", "View download history")) + self.future_settings_button = QPushButton("⚙️") + self.future_settings_button.setFixedWidth(35) + self.future_settings_button.setStyleSheet("padding: 4px 6px;") + self.future_settings_button.clicked.connect(self._show_future_settings_dialog) + char_manage_layout.addWidget(self.add_to_filter_button, 1) + char_manage_layout.addWidget(self.delete_char_button, 1) + char_manage_layout.addWidget(self.known_names_help_button, 0) + char_manage_layout.addWidget(self.history_button, 0) + char_manage_layout.addWidget(self.future_settings_button, 0) + left_layout.addLayout(char_manage_layout) + left_layout.addStretch(0) + + # --- Right Panel (Logs) --- + # (This part of the layout is unchanged and remains correct) + log_title_layout = QHBoxLayout() + self.progress_log_label = QLabel("📜 Progress Log:") + log_title_layout.addWidget(self.progress_log_label) + log_title_layout.addStretch(1) + self.link_search_input = QLineEdit() + self.link_search_input.setPlaceholderText("Search Links...") + self.link_search_input.setVisible(False) + log_title_layout.addWidget(self.link_search_input) + self.link_search_button = QPushButton("🔍") + self.link_search_button.setVisible(False) + self.link_search_button.setFixedWidth(30) + self.link_search_button.setStyleSheet("padding: 4px 4px;") + log_title_layout.addWidget(self.link_search_button) + self.manga_rename_toggle_button = QPushButton() + self.manga_rename_toggle_button.setVisible(False) + self.manga_rename_toggle_button.setFixedWidth(140) + self.manga_rename_toggle_button.setStyleSheet("padding: 4px 8px;") + self._update_manga_filename_style_button_text() + log_title_layout.addWidget(self.manga_rename_toggle_button) + self.manga_date_prefix_input = QLineEdit() + self.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames") + self.manga_date_prefix_input.setVisible(False) + log_title_layout.addWidget(self.manga_date_prefix_input) + self.multipart_toggle_button = QPushButton() + self.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.") + self.multipart_toggle_button.setFixedWidth(130) + self.multipart_toggle_button.setStyleSheet("padding: 4px 8px;") + self._update_multipart_toggle_button_text() + log_title_layout.addWidget(self.multipart_toggle_button) + self.EYE_ICON = "\U0001F441" + self.CLOSED_EYE_ICON = "\U0001F648" + self.log_verbosity_toggle_button = QPushButton(self.EYE_ICON) + self.log_verbosity_toggle_button.setFixedWidth(45) + self.log_verbosity_toggle_button.setStyleSheet("font-size: 11pt; padding: 4px 2px;") + log_title_layout.addWidget(self.log_verbosity_toggle_button) + self.reset_button = QPushButton("🔄 Reset") + self.reset_button.setFixedWidth(80) + self.reset_button.setStyleSheet("padding: 4px 8px;") + log_title_layout.addWidget(self.reset_button) + right_layout.addLayout(log_title_layout) + self.log_splitter = QSplitter(Qt.Vertical) + self.log_view_stack = QStackedWidget() + self.main_log_output = QTextEdit() + self.main_log_output.setReadOnly(True) + self.main_log_output.setLineWrapMode(QTextEdit.NoWrap) + self.log_view_stack.addWidget(self.main_log_output) + self.missed_character_log_output = QTextEdit() + self.missed_character_log_output.setReadOnly(True) + self.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap) + self.log_view_stack.addWidget(self.missed_character_log_output) + self.external_log_output = QTextEdit() + self.external_log_output.setReadOnly(True) + self.external_log_output.setLineWrapMode(QTextEdit.NoWrap) + self.external_log_output.hide() + self.log_splitter.addWidget(self.log_view_stack) + self.log_splitter.addWidget(self.external_log_output) + self.log_splitter.setSizes([self.height(), 0]) + right_layout.addWidget(self.log_splitter, 1) + export_button_layout = QHBoxLayout() + export_button_layout.addStretch(1) + self.export_links_button = QPushButton(self._tr("export_links_button_text", "Export Links")) + self.export_links_button.setFixedWidth(100) + self.export_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") + self.export_links_button.setEnabled(False) + self.export_links_button.setVisible(False) + export_button_layout.addWidget(self.export_links_button) + self.download_extracted_links_button = QPushButton(self._tr("download_extracted_links_button_text", "Download")) + self.download_extracted_links_button.setFixedWidth(100) + self.download_extracted_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") + self.download_extracted_links_button.setEnabled(False) + self.download_extracted_links_button.setVisible(False) + export_button_layout.addWidget(self.download_extracted_links_button) + self.log_display_mode_toggle_button = QPushButton() + self.log_display_mode_toggle_button.setFixedWidth(120) + self.log_display_mode_toggle_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") + self.log_display_mode_toggle_button.setVisible(False) + export_button_layout.addWidget(self.log_display_mode_toggle_button) + right_layout.addLayout(export_button_layout) + 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("") + self.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.") + self.file_progress_label.setWordWrap(True) + self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;") + right_layout.addWidget(self.file_progress_label) + + # --- Final Assembly --- + self.main_splitter.addWidget(left_scroll_area) # Use the scroll area + self.main_splitter.addWidget(right_panel_widget) + self.main_splitter.setStretchFactor(0, 7) + self.main_splitter.setStretchFactor(1, 3) + top_level_layout = QHBoxLayout(self) + top_level_layout.setContentsMargins(0, 0, 0, 0) + top_level_layout.addWidget(self.main_splitter) + + # --- Initial UI State Updates --- + self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked()) + 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: + self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) + if hasattr(self, 'link_input'): + self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)) + self._load_creator_name_cache_from_json() + self.load_known_names_from_util() + self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) + self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) + if hasattr(self, 'radio_group') and self.radio_group.checkedButton(): + self._handle_filter_mode_change(self.radio_group.checkedButton(), True) + self._update_manga_filename_style_button_text() + self._update_skip_scope_button_text() + self._update_char_filter_scope_button_text() + self._update_multithreading_for_date_mode() + if hasattr(self, 'download_thumbnails_checkbox'): + self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked()) + if hasattr(self, 'favorite_mode_checkbox'): + self._handle_favorite_mode_toggle(False) + + def _load_persistent_history (self ): + """Loads download history from a persistent file.""" + self .log_signal .emit (f"📜 Attempting to load history from: {self .persistent_history_file }") + if os .path .exists (self .persistent_history_file ): + try : + with open (self .persistent_history_file ,'r',encoding ='utf-8')as f : + loaded_data =json .load (f ) + + if isinstance (loaded_data ,dict ): + self .last_downloaded_files_details .clear () + self .last_downloaded_files_details .extend (loaded_data .get ("last_downloaded_files",[])) + self .final_download_history_entries =loaded_data .get ("first_processed_posts",[]) + self .log_signal .emit (f"✅ Loaded {len (self .last_downloaded_files_details )} last downloaded files and {len (self .final_download_history_entries )} first processed posts from persistent history.") + elif loaded_data is None and os .path .getsize (self .persistent_history_file )==0 : + self .log_signal .emit (f"ℹ️ Persistent history file is empty. Initializing with empty history.") + self .final_download_history_entries =[] + self .last_downloaded_files_details .clear () + elif isinstance(loaded_data, list): # Handle old format where only first_processed_posts was saved + self.log_signal.emit("⚠️ Persistent history file is in old format (only first_processed_posts). Converting to new format.") + self.final_download_history_entries = loaded_data + self.last_downloaded_files_details.clear() + self._save_persistent_history() # Save in new format immediately + else : + self .log_signal .emit (f"⚠️ Persistent history file has incorrect format. Expected list, got {type (loaded_history )}. Ignoring.") + self .final_download_history_entries =[] + except json .JSONDecodeError : + self .log_signal .emit (f"⚠️ Error decoding persistent history file. It might be corrupted. Ignoring.") + self .final_download_history_entries =[] + except Exception as e : + self .log_signal .emit (f"❌ Error loading persistent history: {e }") + self .final_download_history_entries =[] + else : + self .log_signal .emit (f"⚠️ Persistent history file NOT FOUND at: {self .persistent_history_file }. Starting with empty history.") + self .final_download_history_entries =[] + self ._save_persistent_history () + + + def _save_persistent_history(self): + """Saves download history to a persistent file.""" + self.log_signal.emit(f"📜 Attempting to save history to: {self.persistent_history_file}") + try: + history_dir = os.path.dirname(self.persistent_history_file) + self.log_signal.emit(f" History directory: {history_dir}") + if not os.path.exists(history_dir): + os.makedirs(history_dir, exist_ok=True) + self.log_signal.emit(f" Created history directory: {history_dir}") + + history_data = { + "last_downloaded_files": list(self.last_downloaded_files_details), + "first_processed_posts": self.final_download_history_entries + } + with open(self.persistent_history_file, 'w', encoding='utf-8') as f: + json.dump(history_data, f, indent=2) + self.log_signal.emit(f"✅ Saved {len(self.final_download_history_entries)} history entries to: {self.persistent_history_file}") + except Exception as e: + self.log_signal.emit(f"❌ Error saving persistent history to {self.persistent_history_file}: {e}") + + + def _load_creator_name_cache_from_json (self ): + """Loads creator id-name-service mappings from creators.json into self.creator_name_cache.""" + self .log_signal .emit ("ℹ️ Attempting to load creators.json for creator name cache.") + + if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): + base_path_for_creators =sys ._MEIPASS + else : + base_path_for_creators =self .app_base_dir + + creators_file_path =os .path .join (base_path_for_creators ,"creators.json") + + if not os .path .exists (creators_file_path ): + self .log_signal .emit (f"⚠️ 'creators.json' not found at {creators_file_path }. Creator name cache will be empty.") + self .creator_name_cache .clear () + return + + try : + with open (creators_file_path ,'r',encoding ='utf-8')as f : + loaded_data =json .load (f ) + + creators_list =[] + if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ): + creators_list =loaded_data [0 ] + elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ): + creators_list =loaded_data + else : + self .log_signal .emit (f"⚠️ 'creators.json' has an unexpected format. Creator name cache may be incomplete.") + + for creator_data in creators_list : + creator_id =creator_data .get ("id") + name =creator_data .get ("name") + service =creator_data .get ("service") + if creator_id and name and service : + self .creator_name_cache [(service .lower (),str (creator_id ))]=name + self .log_signal .emit (f"✅ Successfully loaded {len (self .creator_name_cache )} creator names into cache from 'creators.json'.") + except Exception as e : + self .log_signal .emit (f"❌ Error loading 'creators.json' for name cache: {e }") + self .creator_name_cache .clear () + + def _show_download_history_dialog (self ): + """Shows the dialog with the finalized download history.""" + last_3_downloaded =list (self .last_downloaded_files_details ) + first_processed =self .final_download_history_entries + + if not last_3_downloaded and not first_processed : + QMessageBox .information ( + self , + self ._tr ("download_history_dialog_title_empty","Download History (Empty)"), + self ._tr ("no_download_history_header","No Downloads Yet") + ) + return + + dialog =DownloadHistoryDialog (last_3_downloaded ,first_processed ,self ,self ) + dialog .exec_ () + + def _handle_actual_file_downloaded (self ,file_details_dict ): + """Handles a successfully downloaded file for the 'last 3 downloaded' history.""" + if not file_details_dict : + return + file_details_dict ['download_timestamp']=time .time () + creator_key =(file_details_dict .get ('service','').lower (),str (file_details_dict .get ('user_id',''))) + file_details_dict ['creator_display_name']=self .creator_name_cache .get (creator_key ,file_details_dict .get ('folder_context_name','Unknown Creator/Series')) + self .last_downloaded_files_details .append (file_details_dict ) + + + def _handle_file_successfully_downloaded (self ,history_entry_dict ): + """Handles a successfully downloaded file for history logging.""" + if len (self .download_history_log )>=self .download_history_log .maxlen : + self .download_history_log .popleft () + self .download_history_log .append (history_entry_dict ) + + + def _handle_actual_file_downloaded (self ,file_details_dict ): + """Handles a successfully downloaded file for the 'last 3 downloaded' history.""" + if not file_details_dict : + return + + file_details_dict ['download_timestamp']=time .time () + + + creator_key =( + file_details_dict .get ('service','').lower (), + str (file_details_dict .get ('user_id','')) + ) + creator_display_name =self .creator_name_cache .get (creator_key ,file_details_dict .get ('folder_context_name','Unknown Creator')) + file_details_dict ['creator_display_name']=creator_display_name + + self .last_downloaded_files_details .append (file_details_dict ) + + + def _handle_favorite_mode_toggle (self ,checked ): + if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack : + return + + self ._handle_favorite_mode_toggle (self .favorite_mode_checkbox .isChecked ()) + self ._update_favorite_scope_button_text () + if hasattr (self ,'link_input'): + self .last_link_input_text_for_queue_sync =self .link_input .text () + + def _update_download_extracted_links_button_state (self ): + if hasattr (self ,'download_extracted_links_button')and self .download_extracted_links_button : + is_only_links =self .radio_only_links and self .radio_only_links .isChecked () + if not is_only_links : + self .download_extracted_links_button .setEnabled (False ) + return + + supported_platforms_for_button ={'mega','google drive','dropbox'} + has_supported_links =any ( + link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache + ) + self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links ) + + def _show_download_extracted_links_dialog (self ): + """Shows the placeholder dialog for downloading extracted links.""" + if not (self .radio_only_links and self .radio_only_links .isChecked ()): + self .log_signal .emit ("ℹ️ Download extracted links button clicked, but not in 'Only Links' mode.") + return + + supported_platforms ={'mega','google drive','dropbox'} + links_to_show_in_dialog =[] + for link_data_tuple in self .extracted_links_cache : + platform =link_data_tuple [3 ].lower () + if platform in supported_platforms : + links_to_show_in_dialog .append ({ + 'title':link_data_tuple [0 ], + 'link_text':link_data_tuple [1 ], + 'url':link_data_tuple [2 ], + 'platform':platform , + 'key':link_data_tuple [4 ] + }) + + if not links_to_show_in_dialog : + QMessageBox .information (self ,"No Supported Links","No Mega, Google Drive, or Dropbox links were found in the extracted links.") + return + + dialog =DownloadExtractedLinksDialog (links_to_show_in_dialog ,self ,self ) + dialog .download_requested .connect (self ._handle_extracted_links_download_request ) + dialog .exec_ () + + def _handle_extracted_links_download_request (self ,selected_links_info ): + if not selected_links_info : + self .log_signal .emit ("ℹ️ No links selected for download from dialog.") + return + + + if self .radio_only_links and self .radio_only_links .isChecked ()and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS : + self .main_log_output .clear () + self .log_signal .emit ("ℹ️ Displaying Mega download progress (extracted links hidden)...") + self .mega_download_log_preserved_once =False + + current_main_dir =self .dir_input .text ().strip () + download_dir_for_mega ="" + + if current_main_dir and os .path .isdir (current_main_dir ): + download_dir_for_mega =current_main_dir + self .log_signal .emit (f"ℹ️ Using existing main download location for external links: {download_dir_for_mega }") + else : + if not current_main_dir : + self .log_signal .emit ("ℹ️ Main download location is empty. Prompting for download folder.") + else : + self .log_signal .emit ( + f"⚠️ Main download location '{current_main_dir }' is not a valid directory. Prompting for download folder.") + + + suggestion_path =current_main_dir if current_main_dir else QStandardPaths .writableLocation (QStandardPaths .DownloadLocation ) + + chosen_dir =QFileDialog .getExistingDirectory ( + self , + self ._tr ("select_download_folder_mega_dialog_title","Select Download Folder for External Links"), + suggestion_path , + options =QFileDialog .ShowDirsOnly |QFileDialog .DontUseNativeDialog + ) + + if not chosen_dir : + self .log_signal .emit ("ℹ️ External links download cancelled - no download directory selected from prompt.") + return + download_dir_for_mega =chosen_dir + + + self .log_signal .emit (f"ℹ️ Preparing to download {len (selected_links_info )} selected external link(s) to: {download_dir_for_mega }") + if not os .path .exists (download_dir_for_mega ): + self .log_signal .emit (f"❌ Critical Error: Selected download directory '{download_dir_for_mega }' does not exist.") + return + + + tasks_for_thread =selected_links_info + + if self .external_link_download_thread and self .external_link_download_thread .isRunning (): + QMessageBox .warning (self ,"Busy","Another external link download is already in progress.") + return + + self .external_link_download_thread =ExternalLinkDownloadThread ( + tasks_for_thread , + download_dir_for_mega , + self .log_signal .emit , + self + ) + self .external_link_download_thread .finished .connect (self ._on_external_link_download_thread_finished ) + + self .external_link_download_thread .progress_signal .connect (self .handle_main_log ) + self .external_link_download_thread .file_complete_signal .connect (self ._on_single_external_file_complete ) + + + + self .set_ui_enabled (False ) + + self .progress_label .setText (self ._tr ("progress_processing_post_text","Progress: Processing post {processed_posts}...").format (processed_posts =f"External Links (0/{len (tasks_for_thread )})")) + self .external_link_download_thread .start () + + def _on_external_link_download_thread_finished (self ): + self .log_signal .emit ("✅ External link download thread finished.") + self .progress_label .setText (f"{self ._tr ('status_completed','Completed')}: External link downloads. {self ._tr ('ready_for_new_task_text','Ready for new task.')}") + + self .mega_download_log_preserved_once =True + self .log_signal .emit ("INTERNAL: mega_download_log_preserved_once SET to True.") + + if self .radio_only_links and self .radio_only_links .isChecked (): + self .log_signal .emit (HTML_PREFIX +"

--- End of Mega Download Log ---
") + + + + self .set_ui_enabled (True ) + + + + if self .mega_download_log_preserved_once : + self .mega_download_log_preserved_once =False + self .log_signal .emit ("INTERNAL: mega_download_log_preserved_once RESET to False.") + + if self .external_link_download_thread : + self .external_link_download_thread .deleteLater () + self .external_link_download_thread =None + + def _on_single_external_file_complete (self ,url ,success ): + + + pass + def _show_future_settings_dialog (self ): + """Shows the placeholder dialog for future settings.""" + dialog =FutureSettingsDialog (self ) + dialog =FutureSettingsDialog (self ,self ) + dialog .exec_ () + + def _sync_queue_with_link_input (self ,current_text ): + """ + Synchronizes the favorite_download_queue with the link_input text. + Removes creators from the queue if their names are removed from the input field. + Only affects items added via 'creator_popup_selection'. + """ + if not self .favorite_download_queue : + self .last_link_input_text_for_queue_sync =current_text + return + + current_names_in_input ={name .strip ().lower ()for name in current_text .split (',')if name .strip ()} + + queue_copy =list (self .favorite_download_queue ) + removed_count =0 + + for item in queue_copy : + if item .get ('type')=='creator_popup_selection': + item_name_lower =item .get ('name','').lower () + if item_name_lower and item_name_lower not in current_names_in_input : + try : + self .favorite_download_queue .remove (item ) + self .log_signal .emit (f"ℹ️ Creator '{item .get ('name')}' removed from download queue due to removal from URL input.") + removed_count +=1 + except ValueError : + self .log_signal .emit (f"⚠️ Tried to remove '{item .get ('name')}' from queue, but it was not found (sync).") + + self .last_link_input_text_for_queue_sync =current_text + + def _browse_cookie_file (self ): + """Opens a file dialog to select a cookie file.""" + start_dir =QStandardPaths .writableLocation (QStandardPaths .DownloadLocation ) + if not start_dir : + start_dir =os .path .dirname (self .config_file ) + + filepath ,_ =QFileDialog .getOpenFileName (self ,"Select Cookie File",start_dir ,"Text files (*.txt);;All files (*)") + if filepath : + self .selected_cookie_filepath =filepath + self .log_signal .emit (f"ℹ️ Selected cookie file: {filepath }") + if hasattr (self ,'cookie_text_input'): + self .cookie_text_input .blockSignals (True ) + self .cookie_text_input .setText (filepath ) + self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip_file_selected","Using selected cookie file: {filepath}").format (filepath =filepath )) + self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_with_file_selected_text","Using selected cookie file (see Browse...)")) + self .cookie_text_input .setReadOnly (True ) + self .cookie_text_input .setPlaceholderText ("") + self .cookie_text_input .blockSignals (False ) + + def _update_cookie_input_placeholders_and_tooltips (self ): + if hasattr (self ,'cookie_text_input'): + if self .selected_cookie_filepath : + self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_with_file_selected_text","Using selected cookie file...")) + self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip_file_selected","Using selected cookie file: {filepath}").format (filepath =self .selected_cookie_filepath )) + else : + self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_no_file_selected_text","Cookie string (if no cookies.txt selected)")) + self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip","Enter your cookie string directly...")) + self .cookie_text_input .setReadOnly (True ) + self .cookie_text_input .setPlaceholderText ("") + self .cookie_text_input .blockSignals (False ) + + def _center_on_screen (self ): + """Centers the widget on the screen.""" + try : + primary_screen =QApplication .primaryScreen () + if not primary_screen : + screens =QApplication .screens () + if not screens :return + primary_screen =screens [0 ] + + available_geo =primary_screen .availableGeometry () + widget_geo =self .frameGeometry () + + x =available_geo .x ()+(available_geo .width ()-widget_geo .width ())//2 + y =available_geo .y ()+(available_geo .height ()-widget_geo .height ())//2 + self .move (x ,y ) + except Exception as e : + self .log_signal .emit (f"⚠️ Error centering window: {e }") + + def _handle_cookie_text_manual_change (self ,text ): + """Handles manual changes to the cookie text input, especially clearing a browsed path.""" + if not hasattr (self ,'cookie_text_input')or not hasattr (self ,'use_cookie_checkbox'): + return + if self .selected_cookie_filepath and not text .strip ()and self .use_cookie_checkbox .isChecked (): + self .selected_cookie_filepath =None + self .cookie_text_input .setReadOnly (False ) + self ._update_cookie_input_placeholders_and_tooltips () + self .log_signal .emit ("ℹ️ Browsed cookie file path cleared from input. Switched to manual cookie string mode.") + + + 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; + font-family: Consolas, Courier New, monospace; font-size: 9.5pt; } + 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; } + QSplitter::handle:horizontal { width: 5px; } + QSplitter::handle:vertical { height: 5px; } + QFrame[frameShape="4"], QFrame[frameShape="5"] { + border: 1px solid #4A4A4A; + border-radius: 3px; + } + """ + + def browse_directory (self ): + initial_dir_text =self .dir_input .text () + start_path ="" + if initial_dir_text and os .path .isdir (initial_dir_text ): + start_path =initial_dir_text + else : + home_location =QStandardPaths .writableLocation (QStandardPaths .HomeLocation ) + documents_location =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation ) + if home_location and os .path .isdir (home_location ): + start_path =home_location + elif documents_location and os .path .isdir (documents_location ): + start_path =documents_location + + self .log_signal .emit (f"ℹ️ Opening folder dialog. Suggested start path: '{start_path }'") + + try : + folder =QFileDialog .getExistingDirectory ( + self , + "Select Download Folder", + start_path , + options =QFileDialog .DontUseNativeDialog |QFileDialog .ShowDirsOnly + ) + + if folder : + self .dir_input .setText (folder ) + self .log_signal .emit (f"ℹ️ Folder selected: {folder }") + else : + self .log_signal .emit (f"ℹ️ Folder selection cancelled by user.") + except RuntimeError as e : + self .log_signal .emit (f"❌ RuntimeError opening folder dialog: {e }. This might indicate a deeper Qt or system issue.") + QMessageBox .critical (self ,"Dialog Error",f"A runtime error occurred while trying to open the folder dialog: {e }") + except Exception as e : + self .log_signal .emit (f"❌ Unexpected error opening folder dialog: {e }\n{traceback .format_exc (limit =3 )}") + QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }") + + def handle_main_log (self ,message ): + is_html_message =message .startswith (HTML_PREFIX ) + display_message =message + use_html =False + + if is_html_message : + display_message =message [len (HTML_PREFIX ):] + use_html =True + + try : + safe_message =str (display_message ).replace ('\x00','[NULL]') + if use_html : + self .main_log_output .insertHtml (safe_message ) + else : + self .main_log_output .append (safe_message ) + + scrollbar =self .main_log_output .verticalScrollBar () + if scrollbar .value ()>=scrollbar .maximum ()-30 : + scrollbar .setValue (scrollbar .maximum ()) + except Exception as e : + print (f"GUI Main Log Error: {e }\nOriginal Message: {message }") + def _extract_key_term_from_title (self ,title ): + if not title : + return None + title_cleaned =re .sub (r'\[.*?\]','',title ) + title_cleaned =re .sub (r'\(.*?\)','',title_cleaned ) + title_cleaned =title_cleaned .strip () + word_matches =list (re .finditer (r'\b[a-zA-Z][a-zA-Z0-9_-]*\b',title_cleaned )) + + capitalized_candidates =[] + for match in word_matches : + word =match .group (0 ) + if word .istitle ()and word .lower ()not in self .STOP_WORDS and len (word )>2 : + if not (len (word )>3 and word .isupper ()): + capitalized_candidates .append ({'text':word ,'len':len (word ),'pos':match .start ()}) + + if capitalized_candidates : + capitalized_candidates .sort (key =lambda x :(x ['len'],x ['pos']),reverse =True ) + return capitalized_candidates [0 ]['text'] + non_capitalized_words_info =[] + for match in word_matches : + word =match .group (0 ) + if word .lower ()not in self .STOP_WORDS and len (word )>3 : + non_capitalized_words_info .append ({'text':word ,'len':len (word ),'pos':match .start ()}) + + if non_capitalized_words_info : + non_capitalized_words_info .sort (key =lambda x :(x ['len'],x ['pos']),reverse =True ) + return non_capitalized_words_info [0 ]['text'] + + return None + + def handle_missed_character_post (self ,post_title ,reason ): + if self .missed_character_log_output : + key_term =self ._extract_key_term_from_title (post_title ) + + if key_term : + normalized_key_term =key_term .lower () + if normalized_key_term not in self .already_logged_bold_key_terms : + self .already_logged_bold_key_terms .add (normalized_key_term ) + self .missed_key_terms_buffer .append (key_term ) + self ._refresh_missed_character_log () + else : + print (f"Debug (Missed Char Log): Title='{post_title }', Reason='{reason }'") + + def _refresh_missed_character_log (self ): + if self .missed_character_log_output : + self .missed_character_log_output .clear () + sorted_terms =sorted (self .missed_key_terms_buffer ,key =str .lower ) + separator_line ="-"*40 + + for term in sorted_terms : + display_term =term .capitalize () + + self .missed_character_log_output .append (separator_line ) + self .missed_character_log_output .append (f'

{display_term }

') + self .missed_character_log_output .append (separator_line ) + self .missed_character_log_output .append ("") + + scrollbar =self .missed_character_log_output .verticalScrollBar () + scrollbar .setValue (0 ) + + def _is_download_active (self ): + single_thread_active =self .download_thread and self .download_thread .isRunning () + fetcher_active =hasattr (self ,'is_fetcher_thread_running')and self .is_fetcher_thread_running + pool_has_active_tasks =self .thread_pool is not None and any (not f .done ()for f in self .active_futures if f is not None ) + retry_pool_active =hasattr (self ,'retry_thread_pool')and self .retry_thread_pool is not None and hasattr (self ,'active_retry_futures')and any (not f .done ()for f in self .active_retry_futures if f is not None ) + + + external_dl_thread_active =hasattr (self ,'external_link_download_thread')and self .external_link_download_thread is not None and self .external_link_download_thread .isRunning () + + return single_thread_active or fetcher_active or pool_has_active_tasks or retry_pool_active or external_dl_thread_active + + def handle_external_link_signal (self ,post_title ,link_text ,link_url ,platform ,decryption_key ): + link_data =(post_title ,link_text ,link_url ,platform ,decryption_key ) + self .external_link_queue .append (link_data ) + if self .radio_only_links and self .radio_only_links .isChecked (): + self .extracted_links_cache .append (link_data ) + self ._update_download_extracted_links_button_state () + + is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () + should_display_in_external_log =self .show_external_links and not is_only_links_mode + + if not (is_only_links_mode or should_display_in_external_log ): + self ._is_processing_external_link_queue =False + if self .external_link_queue : + QTimer .singleShot (0 ,self ._try_process_next_external_link ) + return + + + if link_data not in self .extracted_links_cache : + self .extracted_links_cache .append (link_data ) + + def _try_process_next_external_link (self ): + if self ._is_processing_external_link_queue or not self .external_link_queue : + return + + is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () + should_display_in_external_log =self .show_external_links and not is_only_links_mode + + if not (is_only_links_mode or should_display_in_external_log ): + self ._is_processing_external_link_queue =False + if self .external_link_queue : + QTimer .singleShot (0 ,self ._try_process_next_external_link ) + return + + self ._is_processing_external_link_queue =True + link_data =self .external_link_queue .popleft () + + if is_only_links_mode : + QTimer .singleShot (0 ,lambda data =link_data :self ._display_and_schedule_next (data )) + elif self ._is_download_active (): + delay_ms =random .randint (4000 ,8000 ) + QTimer .singleShot (delay_ms ,lambda data =link_data :self ._display_and_schedule_next (data )) + else : + QTimer .singleShot (0 ,lambda data =link_data :self ._display_and_schedule_next (data )) + + + def _display_and_schedule_next (self ,link_data ): + post_title ,link_text ,link_url ,platform ,decryption_key =link_data + is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () + + max_link_text_len =50 + display_text =(link_text [:max_link_text_len ].strip ()+"..." + if len (link_text )>max_link_text_len else link_text .strip ()) + formatted_link_info =f"{display_text } - {link_url } - {platform }" + + if decryption_key : + formatted_link_info +=f" (Decryption Key: {decryption_key })" + + if is_only_links_mode : + if post_title !=self ._current_link_post_title : + separator_html ="
"+"-"*45 +"
" + if self ._current_link_post_title is not None : + self .log_signal .emit (HTML_PREFIX +separator_html ) + title_html =f'{html .escape (post_title )}
' + self .log_signal .emit (HTML_PREFIX +title_html ) + self ._current_link_post_title =post_title + + self .log_signal .emit (formatted_link_info ) + elif self .show_external_links : + separator ="-"*45 + self ._append_to_external_log (formatted_link_info ,separator ) + + self ._is_processing_external_link_queue =False + self ._try_process_next_external_link () + + + def _append_to_external_log (self ,formatted_link_text ,separator ): + if not (self .external_log_output and self .external_log_output .isVisible ()): + return + + try : + self .external_log_output .append (formatted_link_text ) + self .external_log_output .append ("") + + scrollbar =self .external_log_output .verticalScrollBar () + if scrollbar .value ()>=scrollbar .maximum ()-50 : + 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 }") + + + def update_file_progress_display (self ,filename ,progress_info ): + if not filename and progress_info is None : + self .file_progress_label .setText ("") + return + + if isinstance (progress_info ,list ): + if not progress_info : + self .file_progress_label .setText (self ._tr ("downloading_multipart_initializing_text","File: {filename} - Initializing parts...").format (filename =filename )) + return + + total_downloaded_overall =sum (cs .get ('downloaded',0 )for cs in progress_info ) + total_file_size_overall =sum (cs .get ('total',0 )for cs in progress_info ) + + active_chunks_count =0 + combined_speed_bps =0 + for cs in progress_info : + if cs .get ('active',False ): + active_chunks_count +=1 + combined_speed_bps +=cs .get ('speed_bps',0 ) + + dl_mb =total_downloaded_overall /(1024 *1024 ) + total_mb =total_file_size_overall /(1024 *1024 ) + speed_MBps =(combined_speed_bps /8 )/(1024 *1024 ) + + progress_text =self ._tr ("downloading_multipart_text","DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts} parts @ {speed:.2f} MB/s)").format (filename =filename [:20 ],downloaded_mb =dl_mb ,total_mb =total_mb ,parts =active_chunks_count ,speed =speed_MBps ) + self .file_progress_label .setText (progress_text ) + + elif isinstance (progress_info ,tuple )and len (progress_info )==2 : + downloaded_bytes ,total_bytes =progress_info + + if not filename and total_bytes ==0 and downloaded_bytes ==0 : + self .file_progress_label .setText ("") + return + + max_fn_len =25 + disp_fn =filename if len (filename )<=max_fn_len else filename [:max_fn_len -3 ].strip ()+"..." + + dl_mb =downloaded_bytes /(1024 *1024 ) + if total_bytes >0 : + tot_mb =total_bytes /(1024 *1024 ) + prog_text_base =self ._tr ("downloading_file_known_size_text","Downloading '{filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)").format (filename =disp_fn ,downloaded_mb =dl_mb ,total_mb =tot_mb ) + else : + prog_text_base =self ._tr ("downloading_file_unknown_size_text","Downloading '{filename}' ({downloaded_mb:.1f}MB)").format (filename =disp_fn ,downloaded_mb =dl_mb ) + + self .file_progress_label .setText (prog_text_base ) + elif filename and progress_info is None : + self .file_progress_label .setText ("") + elif not filename and not progress_info : + self .file_progress_label .setText ("") + + + def update_external_links_setting (self ,checked ): + is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () + is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked () + + if is_only_links_mode or is_only_archives_mode : + if self .external_log_output :self .external_log_output .hide () + if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ]) + return + + self .show_external_links =checked + if checked : + if self .external_log_output :self .external_log_output .show () + if self .log_splitter :self .log_splitter .setSizes ([self .height ()//2 ,self .height ()//2 ]) + if self .main_log_output :self .main_log_output .setMinimumHeight (50 ) + if self .external_log_output :self .external_log_output .setMinimumHeight (50 ) + self .log_signal .emit ("\n"+"="*40 +"\n🔗 External Links Log Enabled\n"+"="*40 ) + if self .external_log_output : + self .external_log_output .clear () + self .external_log_output .append ("🔗 External Links Found:") + self ._try_process_next_external_link () + else : + if self .external_log_output :self .external_log_output .hide () + if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ]) + if self .main_log_output :self .main_log_output .setMinimumHeight (0 ) + if self .external_log_output :self .external_log_output .setMinimumHeight (0 ) + if self .external_log_output :self .external_log_output .clear () + self .log_signal .emit ("\n"+"="*40 +"\n🔗 External Links Log Disabled\n"+"="*40 ) + + + def _handle_filter_mode_change (self ,button ,checked ): + if not button or not checked : + return + + + is_only_links =(button ==self .radio_only_links ) + is_only_audio =(hasattr (self ,'radio_only_audio')and self .radio_only_audio is not None and button ==self .radio_only_audio ) + is_only_archives =(hasattr (self ,'radio_only_archives')and self .radio_only_archives is not None and button ==self .radio_only_archives ) + + if self .skip_scope_toggle_button : + self .skip_scope_toggle_button .setVisible (not (is_only_links or is_only_archives or is_only_audio )) + if hasattr (self ,'multipart_toggle_button')and self .multipart_toggle_button : + self .multipart_toggle_button .setVisible (not (is_only_links or is_only_archives or is_only_audio )) + + if self .link_search_input :self .link_search_input .setVisible (is_only_links ) + if self .link_search_button :self .link_search_button .setVisible (is_only_links ) + if self .export_links_button : + self .export_links_button .setVisible (is_only_links ) + self .export_links_button .setEnabled (is_only_links and bool (self .extracted_links_cache )) + + if hasattr (self ,'download_extracted_links_button')and self .download_extracted_links_button : + self .download_extracted_links_button .setVisible (is_only_links ) + self ._update_download_extracted_links_button_state () + + if self .download_btn : + if is_only_links : + self .download_btn .setText (self ._tr ("extract_links_button_text","🔗 Extract Links")) + else : + self .download_btn .setText (self ._tr ("start_download_button_text","⬇️ Start Download")) + if not is_only_links and self .link_search_input :self .link_search_input .clear () + + file_download_mode_active =not is_only_links + + + + if self .use_subfolders_checkbox :self .use_subfolders_checkbox .setEnabled (file_download_mode_active ) + if self .skip_words_input :self .skip_words_input .setEnabled (file_download_mode_active ) + if self .skip_scope_toggle_button :self .skip_scope_toggle_button .setEnabled (file_download_mode_active ) + if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .setEnabled (file_download_mode_active ) + + if self .skip_zip_checkbox : + can_skip_zip =file_download_mode_active and not is_only_archives + self .skip_zip_checkbox .setEnabled (can_skip_zip ) + if is_only_archives : + self .skip_zip_checkbox .setChecked (False ) + + if self .skip_rar_checkbox : + can_skip_rar =file_download_mode_active and not is_only_archives + self .skip_rar_checkbox .setEnabled (can_skip_rar ) + if is_only_archives : + self .skip_rar_checkbox .setChecked (False ) + + other_file_proc_enabled =file_download_mode_active and not is_only_archives + if self .download_thumbnails_checkbox :self .download_thumbnails_checkbox .setEnabled (other_file_proc_enabled ) + if self .compress_images_checkbox :self .compress_images_checkbox .setEnabled (other_file_proc_enabled ) + + if self .external_links_checkbox : + can_show_external_log_option =file_download_mode_active and not is_only_archives + self .external_links_checkbox .setEnabled (can_show_external_log_option ) + if not can_show_external_log_option : + self .external_links_checkbox .setChecked (False ) + + + if is_only_links : + self .progress_log_label .setText ("📜 Extracted Links Log:") + if self .external_log_output :self .external_log_output .hide () + if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ]) + + + do_clear_log_in_filter_change =True + if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS : + do_clear_log_in_filter_change =False + + if self .main_log_output and do_clear_log_in_filter_change : + self .log_signal .emit ("INTERNAL: _handle_filter_mode_change - About to clear log.") + self .main_log_output .clear () + self .log_signal .emit ("INTERNAL: _handle_filter_mode_change - Log cleared by _handle_filter_mode_change.") + + if self .main_log_output :self .main_log_output .setMinimumHeight (0 ) + self .log_signal .emit ("="*20 +" Mode changed to: Only Links "+"="*20 ) + self ._try_process_next_external_link () + elif is_only_archives : + self .progress_log_label .setText ("📜 Progress Log (Archives Only):") + if self .external_log_output :self .external_log_output .hide () + if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ]) + if self .main_log_output :self .main_log_output .clear () + self .log_signal .emit ("="*20 +" Mode changed to: Only Archives "+"="*20 ) + elif is_only_audio : + self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")+f" ({self ._tr ('filter_audio_radio','🎧 Only Audio')})") + if self .external_log_output :self .external_log_output .hide () + if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ]) + if self .main_log_output :self .main_log_output .clear () + self .log_signal .emit ("="*20 +f" Mode changed to: {self ._tr ('filter_audio_radio','🎧 Only Audio')} "+"="*20 ) + else : + self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")) + self .update_external_links_setting (self .external_links_checkbox .isChecked ()if self .external_links_checkbox else False ) + self .log_signal .emit (f"="*20 +f" Mode changed to: {button .text ()} "+"="*20 ) + + + if is_only_links : + self ._filter_links_log () + + if hasattr (self ,'log_display_mode_toggle_button'): + self .log_display_mode_toggle_button .setVisible (is_only_links ) + self ._update_log_display_mode_button_text () + + subfolders_on =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False + manga_on =self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False + + character_filter_should_be_active =file_download_mode_active and not is_only_archives + + if self .character_filter_widget : + self .character_filter_widget .setVisible (character_filter_should_be_active ) + + enable_character_filter_related_widgets =character_filter_should_be_active + + if self .character_input : + self .character_input .setEnabled (enable_character_filter_related_widgets ) + if not enable_character_filter_related_widgets : + self .character_input .clear () + + if self .char_filter_scope_toggle_button : + self .char_filter_scope_toggle_button .setEnabled (enable_character_filter_related_widgets ) + + self .update_ui_for_subfolders (subfolders_on ) + self .update_custom_folder_visibility () + self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False ) + + + def _filter_links_log (self ): + if not (self .radio_only_links and self .radio_only_links .isChecked ()):return + + search_term =self .link_search_input .text ().lower ().strip ()if self .link_search_input else "" + + if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS : + + + self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log (due to mega_download_log_preserved_once).") + elif self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS : + + + + self .log_signal .emit ("INTERNAL: _filter_links_log - In Progress View. Clearing for placeholder.") + if self .main_log_output :self .main_log_output .clear () + self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for progress placeholder.") + self .log_signal .emit ("ℹ️ Switched to Mega download progress view. Extracted links are hidden.\n" + " Perform a Mega download to see its progress here, or switch back to 🔗 view.") + self .log_signal .emit ("INTERNAL: _filter_links_log - Placeholder message emitted.") + + else : + + self .log_signal .emit ("INTERNAL: _filter_links_log - In links view branch. About to clear.") + if self .main_log_output :self .main_log_output .clear () + self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for links view.") + + current_title_for_display =None + any_links_displayed_this_call =False + separator_html ="
"+"-"*45 +"
" + + for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache : + matches_search =(not search_term or + search_term in link_text .lower ()or + search_term in link_url .lower ()or + search_term in platform .lower ()or + (decryption_key and search_term in decryption_key .lower ())) + if not matches_search : + continue + + any_links_displayed_this_call =True + if post_title !=current_title_for_display : + if current_title_for_display is not None : + if self .main_log_output :self .main_log_output .insertHtml (separator_html ) + + title_html =f'{html .escape (post_title )}
' + if self .main_log_output :self .main_log_output .insertHtml (title_html ) + current_title_for_display =post_title + + max_link_text_len =50 + display_text =(link_text [:max_link_text_len ].strip ()+"..."if len (link_text )>max_link_text_len else link_text .strip ()) + + plain_link_info_line =f"{display_text } - {link_url } - {platform }" + if decryption_key : + plain_link_info_line +=f" (Decryption Key: {decryption_key })" + if self .main_log_output : + self .main_log_output .append (plain_link_info_line ) + + if any_links_displayed_this_call : + if self .main_log_output :self .main_log_output .append ("") + elif not search_term and self .main_log_output : + self .log_signal .emit (" (No links extracted yet or all filtered out in links view)") + + + if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ()) + + + def _export_links_to_file (self ): + if not (self .radio_only_links and self .radio_only_links .isChecked ()): + QMessageBox .information (self ,"Export Links","Link export is only available in 'Only Links' mode.") + return + if not self .extracted_links_cache : + QMessageBox .information (self ,"Export Links","No links have been extracted yet.") + return + + default_filename ="extracted_links.txt" + filepath ,_ =QFileDialog .getSaveFileName (self ,"Save Links",default_filename ,"Text Files (*.txt);;All Files (*)") + + if filepath : + try : + with open (filepath ,'w',encoding ='utf-8')as f : + current_title_for_export =None + separator ="-"*60 +"\n" + for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache : + if post_title !=current_title_for_export : + if current_title_for_export is not None : + f .write ("\n"+separator +"\n") + f .write (f"Post Title: {post_title }\n\n") + current_title_for_export =post_title + line_to_write =f" {link_text } - {link_url } - {platform }" + if decryption_key : + line_to_write +=f" (Decryption Key: {decryption_key })" + f .write (line_to_write +"\n") + self .log_signal .emit (f"✅ Links successfully exported to: {filepath }") + QMessageBox .information (self ,"Export Successful",f"Links exported to:\n{filepath }") + except Exception as e : + self .log_signal .emit (f"❌ Error exporting links: {e }") + QMessageBox .critical (self ,"Export Error",f"Could not export links: {e }") + + + def get_filter_mode (self ): + if self .radio_only_links and self .radio_only_links .isChecked (): + return 'all' + elif self .radio_images .isChecked (): + return 'image' + elif self .radio_videos .isChecked (): + return 'video' + elif self .radio_only_archives and self .radio_only_archives .isChecked (): + return 'archive' + elif hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked (): + return 'audio' + elif self .radio_all .isChecked (): + return 'all' + return 'all' + + + def get_skip_words_scope (self ): + return self .skip_words_scope + + + def _update_skip_scope_button_text (self ): + if self .skip_scope_toggle_button : + if self .skip_words_scope ==SKIP_SCOPE_FILES : + self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_files_text","Scope: Files")) + self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_files_tooltip","Tooltip for skip scope files")) + elif self .skip_words_scope ==SKIP_SCOPE_POSTS : + self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_posts_text","Scope: Posts")) + self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_posts_tooltip","Tooltip for skip scope posts")) + elif self .skip_words_scope ==SKIP_SCOPE_BOTH : + self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_both_text","Scope: Both")) + self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_both_tooltip","Tooltip for skip scope both")) + else : + self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_unknown_text","Scope: Unknown")) + self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_unknown_tooltip","Tooltip for skip scope unknown")) + + + def _cycle_skip_scope (self ): + if self .skip_words_scope ==SKIP_SCOPE_POSTS : + self .skip_words_scope =SKIP_SCOPE_FILES + elif self .skip_words_scope ==SKIP_SCOPE_FILES : + self .skip_words_scope =SKIP_SCOPE_BOTH + elif self .skip_words_scope ==SKIP_SCOPE_BOTH : + self .skip_words_scope =SKIP_SCOPE_POSTS + else : + self .skip_words_scope =SKIP_SCOPE_POSTS + + self ._update_skip_scope_button_text () + self .settings .setValue (SKIP_WORDS_SCOPE_KEY ,self .skip_words_scope ) + self .log_signal .emit (f"ℹ️ Skip words scope changed to: '{self .skip_words_scope }'") + + def get_char_filter_scope (self ): + return self .char_filter_scope + + def _update_char_filter_scope_button_text (self ): + if self .char_filter_scope_toggle_button : + if self .char_filter_scope ==CHAR_SCOPE_FILES : + self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_files_text","Filter: Files")) + self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_files_tooltip","Tooltip for char filter files")) + elif self .char_filter_scope ==CHAR_SCOPE_TITLE : + self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_title_text","Filter: Title")) + self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_title_tooltip","Tooltip for char filter title")) + elif self .char_filter_scope ==CHAR_SCOPE_BOTH : + self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_both_text","Filter: Both")) + self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_both_tooltip","Tooltip for char filter both")) + elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS : + self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_comments_text","Filter: Comments (Beta)")) + self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_comments_tooltip","Tooltip for char filter comments")) + else : + self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_unknown_text","Filter: Unknown")) + self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_unknown_tooltip","Tooltip for char filter unknown")) + + def _cycle_char_filter_scope (self ): + if self .char_filter_scope ==CHAR_SCOPE_TITLE : + self .char_filter_scope =CHAR_SCOPE_FILES + elif self .char_filter_scope ==CHAR_SCOPE_FILES : + self .char_filter_scope =CHAR_SCOPE_BOTH + elif self .char_filter_scope ==CHAR_SCOPE_BOTH : + self .char_filter_scope =CHAR_SCOPE_COMMENTS + elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS : + self .char_filter_scope =CHAR_SCOPE_TITLE + else : + self .char_filter_scope =CHAR_SCOPE_TITLE + + self ._update_char_filter_scope_button_text () + self .settings .setValue (CHAR_FILTER_SCOPE_KEY ,self .char_filter_scope ) + self .log_signal .emit (f"ℹ️ Character filter scope changed to: '{self .char_filter_scope }'") + + def _handle_ui_add_new_character (self ): + """Handles adding a new character from the UI input field.""" + name_from_ui_input =self .new_char_input .text ().strip () + successfully_added_any =False + + if not name_from_ui_input : + QMessageBox .warning (self ,"Input Error","Name cannot be empty.") + return + + if name_from_ui_input .startswith ("(")and name_from_ui_input .endswith (")~"): + content =name_from_ui_input [1 :-2 ].strip () + aliases =[alias .strip ()for alias in content .split (',')if alias .strip ()] + if aliases : + folder_name =" ".join (aliases ) + if self .add_new_character (name_to_add =folder_name , + is_group_to_add =True , + aliases_to_add =aliases , + suppress_similarity_prompt =False ): + successfully_added_any =True + else : + QMessageBox .warning (self ,"Input Error","Empty group content for `~` format.") + + elif name_from_ui_input .startswith ("(")and name_from_ui_input .endswith (")"): + content =name_from_ui_input [1 :-1 ].strip () + names_to_add_separately =[name .strip ()for name in content .split (',')if name .strip ()] + if names_to_add_separately : + for name_item in names_to_add_separately : + if self .add_new_character (name_to_add =name_item , + is_group_to_add =False , + aliases_to_add =[name_item ], + suppress_similarity_prompt =False ): + successfully_added_any =True + else : + QMessageBox .warning (self ,"Input Error","Empty group content for standard group format.") + else : + if self .add_new_character (name_to_add =name_from_ui_input , + is_group_to_add =False , + aliases_to_add =[name_from_ui_input ], + suppress_similarity_prompt =False ): + successfully_added_any =True + + if successfully_added_any : + self .new_char_input .clear () + self .save_known_names () + + + def add_new_character (self ,name_to_add ,is_group_to_add ,aliases_to_add ,suppress_similarity_prompt =False ): + global KNOWN_NAMES ,clean_folder_name + if not name_to_add : + QMessageBox .warning (self ,"Input Error","Name cannot be empty.");return False + + name_to_add_lower =name_to_add .lower () + for kn_entry in KNOWN_NAMES : + if kn_entry ["name"].lower ()==name_to_add_lower : + QMessageBox .warning (self ,"Duplicate Name",f"The primary folder name '{name_to_add }' already exists.");return False + if not is_group_to_add and name_to_add_lower in [a .lower ()for a in kn_entry ["aliases"]]: + QMessageBox .warning (self ,"Duplicate Alias",f"The name '{name_to_add }' already exists as an alias for '{kn_entry ['name']}'.");return False + + similar_names_details =[] + for kn_entry in KNOWN_NAMES : + for term_to_check_similarity_against in kn_entry ["aliases"]: + term_lower =term_to_check_similarity_against .lower () + if name_to_add_lower !=term_lower and (name_to_add_lower in term_lower or term_lower in name_to_add_lower ): + similar_names_details .append ((name_to_add ,kn_entry ["name"])) + break + for new_alias in aliases_to_add : + if new_alias .lower ()!=term_to_check_similarity_against .lower ()and (new_alias .lower ()in term_to_check_similarity_against .lower ()or term_to_check_similarity_against .lower ()in new_alias .lower ()): + similar_names_details .append ((new_alias ,kn_entry ["name"])) + break + + if similar_names_details and not suppress_similarity_prompt : + if similar_names_details : + first_similar_new ,first_similar_existing =similar_names_details [0 ] + shorter ,longer =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"This could lead to unexpected folder grouping (e.g., under '{clean_folder_name (shorter )}' instead of a more specific '{clean_folder_name (longer )}' or vice-versa).\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 ) + msg_box .setEscapeButton (change_button ) + msg_box .exec_ () + + if msg_box .clickedButton ()==change_button : + self .log_signal .emit (f"ℹ️ User chose to change '{first_similar_new }' due to similarity with an alias of '{first_similar_existing }'.") + return False + self .log_signal .emit (f"⚠️ User proceeded with adding '{first_similar_new }' despite similarity with an alias of '{first_similar_existing }'.") + new_entry ={ + "name":name_to_add , + "is_group":is_group_to_add , + "aliases":sorted (list (set (aliases_to_add )),key =str .lower ) + } + if is_group_to_add : + for new_alias in new_entry ["aliases"]: + if any (new_alias .lower ()==kn_entry ["name"].lower ()for kn_entry in KNOWN_NAMES if kn_entry ["name"].lower ()!=name_to_add_lower ): + QMessageBox .warning (self ,"Alias Conflict",f"Alias '{new_alias }' (for group '{name_to_add }') conflicts with an existing primary name.");return False + KNOWN_NAMES .append (new_entry ) + KNOWN_NAMES .sort (key =lambda x :x ["name"].lower ()) + + self .character_list .clear () + self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ]) + self .filter_character_list (self .character_search_input .text ()) + + log_msg_suffix =f" (as group with aliases: {', '.join (new_entry ['aliases'])})"if is_group_to_add and len (new_entry ['aliases'])>1 else "" + self .log_signal .emit (f"✅ Added '{name_to_add }' to known names list{log_msg_suffix }.") + self .new_char_input .clear () + return True + + + 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 + + primary_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 (primary_names_to_remove )} selected entry/entries (and their aliases)?", + QMessageBox .Yes |QMessageBox .No ,QMessageBox .No ) + if confirm ==QMessageBox .Yes : + original_count =len (KNOWN_NAMES ) + KNOWN_NAMES [:]=[entry for entry in KNOWN_NAMES if entry ["name"]not in primary_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 () + self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ]) + self .filter_character_list (self .character_search_input .text ()) + self .save_known_names () + else : + self .log_signal .emit ("ℹ️ No names were removed (they might not have been in the list).") + + + def update_custom_folder_visibility (self ,url_text =None ): + if url_text is None : + url_text =self .link_input .text () + + _ ,_ ,post_id =extract_post_info (url_text .strip ()) + + is_single_post_url =bool (post_id ) + subfolders_enabled =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False + + not_only_links_or_archives_mode =not ( + (self .radio_only_links and self .radio_only_links .isChecked ())or + (self .radio_only_archives and self .radio_only_archives .isChecked ())or + (hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()) + ) + + should_show_custom_folder =is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode + + if self .custom_folder_widget : + self .custom_folder_widget .setVisible (should_show_custom_folder ) + + if not (self .custom_folder_widget and self .custom_folder_widget .isVisible ()): + if self .custom_folder_input :self .custom_folder_input .clear () + + + def update_ui_for_subfolders (self ,separate_folders_by_name_title_checked :bool ): + is_only_links =self .radio_only_links and self .radio_only_links .isChecked () + is_only_archives =self .radio_only_archives and self .radio_only_archives .isChecked () + is_only_audio =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked () + + can_enable_subfolder_per_post_checkbox =not is_only_links + + if self .use_subfolder_per_post_checkbox : + self .use_subfolder_per_post_checkbox .setEnabled (can_enable_subfolder_per_post_checkbox ) + + if not can_enable_subfolder_per_post_checkbox : + self .use_subfolder_per_post_checkbox .setChecked (False ) + + if hasattr(self, 'date_prefix_checkbox'): + # The Date Prefix checkbox should only be enabled if "Subfolder per Post" is both enabled and checked + can_enable_date_prefix = self.use_subfolder_per_post_checkbox.isEnabled() and self.use_subfolder_per_post_checkbox.isChecked() + self.date_prefix_checkbox.setEnabled(can_enable_date_prefix) + if not can_enable_date_prefix: + self.date_prefix_checkbox.setChecked(False) + + self .update_custom_folder_visibility () + + + def _update_cookie_input_visibility (self ,checked ): + cookie_text_input_exists =hasattr (self ,'cookie_text_input') + cookie_browse_button_exists =hasattr (self ,'cookie_browse_button') + + if cookie_text_input_exists or cookie_browse_button_exists : + is_only_links =self .radio_only_links and self .radio_only_links .isChecked () + if cookie_text_input_exists :self .cookie_text_input .setVisible (checked ) + if cookie_browse_button_exists :self .cookie_browse_button .setVisible (checked ) + + can_enable_cookie_text =checked and not is_only_links + enable_state_for_fields =can_enable_cookie_text and (self .download_btn .isEnabled ()or self .is_paused ) + + if cookie_text_input_exists : + self .cookie_text_input .setEnabled (enable_state_for_fields ) + if self .selected_cookie_filepath and checked : + self .cookie_text_input .setText (self .selected_cookie_filepath ) + self .cookie_text_input .setReadOnly (True ) + self .cookie_text_input .setPlaceholderText ("") + elif checked : + self .cookie_text_input .setReadOnly (False ) + self .cookie_text_input .setPlaceholderText ("Cookie string (if no cookies.txt)") + + if cookie_browse_button_exists :self .cookie_browse_button .setEnabled (enable_state_for_fields ) + + if not checked : + self .selected_cookie_filepath =None + + + def update_page_range_enabled_state (self ): + url_text =self .link_input .text ().strip ()if self .link_input else "" + _ ,_ ,post_id =extract_post_info (url_text ) + + is_creator_feed =not post_id if url_text else False + enable_page_range =is_creator_feed + + 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 : + if self .start_page_input :self .start_page_input .clear () + if self .end_page_input :self .end_page_input .clear () + + + def _update_manga_filename_style_button_text (self ): + if self .manga_rename_toggle_button : + if self .manga_filename_style ==STYLE_POST_TITLE : + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_title_text","Name: Post Title")) + + elif self .manga_filename_style ==STYLE_ORIGINAL_NAME : + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_original_file_text","Name: Original File")) + + elif self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_title_global_num_text","Name: Title+G.Num")) + + elif self .manga_filename_style ==STYLE_DATE_BASED : + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_based_text","Name: Date Based")) + + elif self .manga_filename_style ==STYLE_POST_ID: # Add this block + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_id_text","Name: Post ID")) + + elif self .manga_filename_style ==STYLE_DATE_POST_TITLE : + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_post_title_text","Name: Date + Title")) + + else : + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_unknown_text","Name: Unknown Style")) + + + self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).") + + +# In main_window.py + + def _toggle_manga_filename_style (self ): + current_style =self .manga_filename_style + new_style ="" + if current_style ==STYLE_POST_TITLE : + new_style =STYLE_ORIGINAL_NAME + elif current_style ==STYLE_ORIGINAL_NAME : + new_style =STYLE_DATE_POST_TITLE + elif current_style ==STYLE_DATE_POST_TITLE : + new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING + elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : + new_style =STYLE_DATE_BASED + elif current_style ==STYLE_DATE_BASED : + new_style =STYLE_POST_ID # Change this line + elif current_style ==STYLE_POST_ID: # Add this block + new_style =STYLE_POST_TITLE + else : + self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').") + new_style =STYLE_POST_TITLE + + self .manga_filename_style =new_style + self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style ) + self .settings .sync () + self ._update_manga_filename_style_button_text () + self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False ) + self .log_signal .emit (f"ℹ️ Manga filename style changed to: '{self .manga_filename_style }'") + + def _handle_favorite_mode_toggle (self ,checked ): + if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack : + return + + self .url_or_placeholder_stack .setCurrentIndex (1 if checked else 0 ) + self .bottom_action_buttons_stack .setCurrentIndex (1 if checked else 0 ) + + if checked : + if self .link_input : + self .link_input .clear () + self .link_input .setEnabled (False ) + for widget in [self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ]: + if widget :widget .setEnabled (False ) + if self .start_page_input :self .start_page_input .clear () + if self .end_page_input :self .end_page_input .clear () + + self .update_custom_folder_visibility () + self .update_page_range_enabled_state () + if self .manga_mode_checkbox : + self .manga_mode_checkbox .setChecked (False ) + self .manga_mode_checkbox .setEnabled (False ) + if hasattr (self ,'use_cookie_checkbox'): + self .use_cookie_checkbox .setChecked (True ) + self .use_cookie_checkbox .setEnabled (False ) + if hasattr (self ,'use_cookie_checkbox'): + self ._update_cookie_input_visibility (True ) + self .update_ui_for_manga_mode (False ) + + if hasattr (self ,'favorite_mode_artists_button'): + self .favorite_mode_artists_button .setEnabled (True ) + if hasattr (self ,'favorite_mode_posts_button'): + self .favorite_mode_posts_button .setEnabled (True ) + + else : + if self .link_input :self .link_input .setEnabled (True ) + self .update_page_range_enabled_state () + self .update_custom_folder_visibility () + self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False ) + + if hasattr (self ,'use_cookie_checkbox'): + self .use_cookie_checkbox .setEnabled (True ) + if hasattr (self ,'use_cookie_checkbox'): + self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()) + + if hasattr (self ,'favorite_mode_artists_button'): + self .favorite_mode_artists_button .setEnabled (False ) + if hasattr (self ,'favorite_mode_posts_button'): + self .favorite_mode_posts_button .setEnabled (False ) + + def update_ui_for_manga_mode (self ,checked ): + is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () + is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked () + is_only_audio_mode =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked () + + url_text =self .link_input .text ().strip ()if self .link_input else "" + _ ,_ ,post_id =extract_post_info (url_text ) + + is_creator_feed =not post_id if url_text else False + is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False + + if self .manga_mode_checkbox : + self .manga_mode_checkbox .setEnabled (is_creator_feed and not is_favorite_mode_on ) + if not is_creator_feed and self .manga_mode_checkbox .isChecked (): + self .manga_mode_checkbox .setChecked (False ) + checked =self .manga_mode_checkbox .isChecked () + + manga_mode_effectively_on =is_creator_feed and checked + + if self .manga_rename_toggle_button : + self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )) + + self .update_page_range_enabled_state () + + current_filename_style =self .manga_filename_style + + enable_char_filter_widgets =not is_only_links_mode and not is_only_archives_mode + + if self .character_input : + self .character_input .setEnabled (enable_char_filter_widgets ) + if not enable_char_filter_widgets :self .character_input .clear () + if self .char_filter_scope_toggle_button : + self .char_filter_scope_toggle_button .setEnabled (enable_char_filter_widgets ) + if self .character_filter_widget : + self .character_filter_widget .setVisible (enable_char_filter_widgets ) + + show_date_prefix_input =( + manga_mode_effectively_on and + (current_filename_style ==STYLE_DATE_BASED or + current_filename_style ==STYLE_ORIGINAL_NAME )and + not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ) + ) + if hasattr (self ,'manga_date_prefix_input'): + self .manga_date_prefix_input .setVisible (show_date_prefix_input ) + if show_date_prefix_input : + self .manga_date_prefix_input .setMaximumWidth (120 ) + self .manga_date_prefix_input .setMinimumWidth (60 ) + else : + self .manga_date_prefix_input .clear () + self .manga_date_prefix_input .setMaximumWidth (16777215 ) + self .manga_date_prefix_input .setMinimumWidth (0 ) + + if hasattr (self ,'multipart_toggle_button'): + + hide_multipart_button_due_mode =is_only_links_mode or is_only_archives_mode or is_only_audio_mode + hide_multipart_button_due_manga_mode =manga_mode_effectively_on + self .multipart_toggle_button .setVisible (not (hide_multipart_button_due_mode or hide_multipart_button_due_manga_mode )) + + self ._update_multithreading_for_date_mode () + + + 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 ): + if self .use_multithreading_checkbox .isChecked (): + base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading") + try : + num_threads_val =int (text ) + if num_threads_val >0 :self .use_multithreading_checkbox .setText (f"{base_text } ({num_threads_val } Threads)") + else :self .use_multithreading_checkbox .setText (f"{base_text } (Invalid: >0)") + except ValueError : + self .use_multithreading_checkbox .setText (f"{base_text } (Invalid Input)") + else : + self .use_multithreading_checkbox .setText (f"{self ._tr ('use_multithreading_checkbox_base_label','Use Multithreading')} (1 Thread)") + + + def _handle_multithreading_toggle (self ,checked ): + if not checked : + self .thread_count_input .setEnabled (False ) + self .thread_count_label .setEnabled (False ) + self .use_multithreading_checkbox .setText ("Use Multithreading (1 Thread)") + else : + self .thread_count_input .setEnabled (True ) + self .thread_count_label .setEnabled (True ) + self .update_multithreading_label (self .thread_count_input .text ()) + + def _update_multithreading_for_date_mode (self ): + """ + Checks if Manga Mode is ON and 'Date Based' style is selected. + If so, disables multithreading. Otherwise, enables it. + """ + if not hasattr (self ,'manga_mode_checkbox')or not hasattr (self ,'use_multithreading_checkbox'): + return + + manga_on =self .manga_mode_checkbox .isChecked () + is_sequential_style_requiring_single_thread =( + self .manga_filename_style ==STYLE_DATE_BASED or + self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING + ) + if manga_on and is_sequential_style_requiring_single_thread : + if self .use_multithreading_checkbox .isChecked ()or self .use_multithreading_checkbox .isEnabled (): + if self .use_multithreading_checkbox .isChecked (): + self .log_signal .emit ("ℹ️ Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.") + self .use_multithreading_checkbox .setChecked (False ) + self .use_multithreading_checkbox .setEnabled (False ) + self ._handle_multithreading_toggle (False ) + else : + if not self .use_multithreading_checkbox .isEnabled (): + self .use_multithreading_checkbox .setEnabled (True ) + self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ()) + + def update_progress_display (self ,total_posts ,processed_posts ): + if total_posts >0 : + progress_percent =(processed_posts /total_posts )*100 + self .progress_label .setText (self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =processed_posts ,total_posts =total_posts ,progress_percent =progress_percent )) + elif processed_posts >0 : + self .progress_label .setText (self ._tr ("progress_processing_post_text","Progress: Processing post {processed_posts}...").format (processed_posts =processed_posts )) + else : + self .progress_label .setText (self ._tr ("progress_starting_text","Progress: Starting...")) + + if total_posts >0 or processed_posts >0 : + self .file_progress_label .setText ("") + + + def start_download (self ,direct_api_url =None ,override_output_dir =None, is_restore=False ): + global KNOWN_NAMES ,BackendDownloadThread ,PostProcessorWorker ,extract_post_info ,clean_folder_name ,MAX_FILE_THREADS_PER_POST_OR_WORKER + + if self ._is_download_active (): + QMessageBox.warning(self, "Busy", "A download is already in progress.") + return False + + + + if not direct_api_url and self .favorite_download_queue and not self .is_processing_favorites_queue : + self .log_signal .emit (f"ℹ️ Detected {len (self .favorite_download_queue )} item(s) in the queue. Starting processing...") + self .cancellation_message_logged_this_session =False + self ._process_next_favorite_download () + return True + + if not is_restore and self.interrupted_session_data: + self.log_signal.emit("ℹ️ New download started. Discarding previous interrupted session.") + self._clear_session_file() + self.interrupted_session_data = None + self.is_restore_pending = False + api_url =direct_api_url if direct_api_url else self .link_input .text ().strip () + self .download_history_candidates .clear () + self._update_button_states_and_connections() # Ensure buttons are updated to active state + + + if self .favorite_mode_checkbox and self .favorite_mode_checkbox .isChecked ()and not direct_api_url and not api_url : + QMessageBox .information (self ,"Favorite Mode Active", + "Favorite Mode is active. Please use the 'Favorite Artists' or 'Favorite Posts' buttons to start downloads in this mode, or uncheck 'Favorite Mode' to use the URL input.") + self .set_ui_enabled (True ) + return False + + main_ui_download_dir =self .dir_input .text ().strip () + + if not api_url and not self .favorite_download_queue : + QMessageBox .critical (self ,"Input Error","URL is required.") + return False + elif not api_url and self .favorite_download_queue : + self .log_signal .emit ("ℹ️ URL input is empty, but queue has items. Processing queue...") + self .cancellation_message_logged_this_session =False + self ._process_next_favorite_download () + return True + + self .cancellation_message_logged_this_session =False + use_subfolders =self .use_subfolders_checkbox .isChecked () + use_post_subfolders =self .use_subfolder_per_post_checkbox .isChecked () + compress_images =self .compress_images_checkbox .isChecked () + download_thumbnails =self .download_thumbnails_checkbox .isChecked () + + use_multithreading_enabled_by_checkbox =self .use_multithreading_checkbox .isChecked () + try : + num_threads_from_gui =int (self .thread_count_input .text ().strip ()) + if num_threads_from_gui <1 :num_threads_from_gui =1 + except ValueError : + QMessageBox .critical (self ,"Thread Count Error","Invalid number of threads. Please enter a positive number.") + return False + + if use_multithreading_enabled_by_checkbox : + if num_threads_from_gui >MAX_THREADS : + hard_warning_msg =( + f"You've entered a thread count ({num_threads_from_gui }) exceeding the maximum of {MAX_THREADS }.\n\n" + "Using an extremely high number of threads can lead to:\n" + " - Diminishing returns (no significant speed increase).\n" + " - Increased system instability or application crashes.\n" + " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n" + f"The thread count has been automatically capped to {MAX_THREADS } for stability." + ) + QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg ) + num_threads_from_gui =MAX_THREADS + self .thread_count_input .setText (str (MAX_THREADS )) + self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.") + if SOFT_WARNING_THREAD_THRESHOLD MAX_THREADS : + hard_warning_msg =( + f"You've entered a thread count ({num_threads_from_gui }) exceeding the maximum of {MAX_THREADS }.\n\n" + "Using an extremely high number of threads can lead to:\n" + " - Diminishing returns (no significant speed increase).\n" + " - Increased system instability or application crashes.\n" + " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n" + f"The thread count has been automatically capped to {MAX_THREADS } for stability." + ) + QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg ) + num_threads_from_gui =MAX_THREADS + self .thread_count_input .setText (str (MAX_THREADS )) + self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.") + if SOFT_WARNING_THREAD_THRESHOLD end_page :raise ValueError ("Start page cannot be greater than end page.") + + if manga_mode and start_page and end_page : + msg_box =QMessageBox (self ) + msg_box .setIcon (QMessageBox .Warning ) + msg_box .setWindowTitle ("Manga Mode & Page Range Warning") + msg_box .setText ( + "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n" + "Manga Mode processes posts from oldest to newest across all available pages by default.\n" + "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n" + "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n" + "Do you want to proceed with this page range in Manga Mode?" + ) + proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole ) + cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole ) + msg_box .setDefaultButton (proceed_button ) + msg_box .setEscapeButton (cancel_button ) + msg_box .exec_ () + + if msg_box .clickedButton ()==cancel_button : + self .log_signal .emit ("❌ Download cancelled by user due to Manga Mode & Page Range warning.") + return False + except ValueError as e : + QMessageBox .critical (self ,"Page Range Error",f"Invalid page range: {e }") + return False + self .external_link_queue .clear ();self .extracted_links_cache =[];self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None + + raw_character_filters_text =self .character_input .text ().strip () + parsed_character_filter_objects =self ._parse_character_filters (raw_character_filters_text ) + + actual_filters_to_use_for_run =[] + + needs_folder_naming_validation =(use_subfolders or manga_mode )and not extract_links_only + + if parsed_character_filter_objects : + actual_filters_to_use_for_run =parsed_character_filter_objects + + if not extract_links_only : + self .log_signal .emit (f"ℹ️ Using character filters for matching: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}") + + filter_objects_to_potentially_add_to_known_list =[] + for filter_item_obj in parsed_character_filter_objects : + item_primary_name =filter_item_obj ["name"] + cleaned_name_test =clean_folder_name (item_primary_name ) + if needs_folder_naming_validation and not cleaned_name_test : + QMessageBox .warning (self ,"Invalid Filter Name for Folder",f"Filter name '{item_primary_name }' is invalid for a folder and will be skipped for Known.txt interaction.") + self .log_signal .emit (f"⚠️ Skipping invalid filter for Known.txt interaction: '{item_primary_name }'") + continue + + an_alias_is_already_known =False + if any (kn_entry ["name"].lower ()==item_primary_name .lower ()for kn_entry in KNOWN_NAMES ): + an_alias_is_already_known =True + elif filter_item_obj ["is_group"]and needs_folder_naming_validation : + for alias_in_filter_obj in filter_item_obj ["aliases"]: + if any (kn_entry ["name"].lower ()==alias_in_filter_obj .lower ()or alias_in_filter_obj .lower ()in [a .lower ()for a in kn_entry ["aliases"]]for kn_entry in KNOWN_NAMES ): + an_alias_is_already_known =True ;break + + if an_alias_is_already_known and filter_item_obj ["is_group"]: + self .log_signal .emit (f"ℹ️ An alias from group '{item_primary_name }' is already known. Group will not be prompted for Known.txt addition.") + + should_prompt_to_add_to_known_list =( + needs_folder_naming_validation and not manga_mode and + not any (kn_entry ["name"].lower ()==item_primary_name .lower ()for kn_entry in KNOWN_NAMES )and + not an_alias_is_already_known + ) + if should_prompt_to_add_to_known_list : + if not any (obj_to_add ["name"].lower ()==item_primary_name .lower ()for obj_to_add in filter_objects_to_potentially_add_to_known_list ): + filter_objects_to_potentially_add_to_known_list .append (filter_item_obj ) + elif manga_mode and needs_folder_naming_validation and item_primary_name .lower ()not in {kn_entry ["name"].lower ()for kn_entry in KNOWN_NAMES }and not an_alias_is_already_known : + self .log_signal .emit (f"ℹ️ Manga Mode: Using filter '{item_primary_name }' for this session without adding to Known Names.") + + if filter_objects_to_potentially_add_to_known_list : + confirm_dialog =ConfirmAddAllDialog (filter_objects_to_potentially_add_to_known_list ,self ,self ) + dialog_result =confirm_dialog .exec_ () + + if dialog_result ==CONFIRM_ADD_ALL_CANCEL_DOWNLOAD : + self .log_signal .emit ("❌ Download cancelled by user at new name confirmation stage.") + return False + elif isinstance (dialog_result ,list ): + if dialog_result : + self .log_signal .emit (f"ℹ️ User chose to add {len (dialog_result )} new entry/entries to Known.txt.") + for filter_obj_to_add in dialog_result : + if filter_obj_to_add .get ("components_are_distinct_for_known_txt"): + self .log_signal .emit (f" Processing group '{filter_obj_to_add ['name']}' to add its components individually to Known.txt.") + for alias_component in filter_obj_to_add ["aliases"]: + self .add_new_character ( + name_to_add =alias_component , + is_group_to_add =False , + aliases_to_add =[alias_component ], + suppress_similarity_prompt =True + ) + else : + self .add_new_character ( + name_to_add =filter_obj_to_add ["name"], + is_group_to_add =filter_obj_to_add ["is_group"], + aliases_to_add =filter_obj_to_add ["aliases"], + suppress_similarity_prompt =True + ) + else : + self .log_signal .emit ("ℹ️ User confirmed adding, but no names were selected in the dialog. No new names added to Known.txt.") + elif dialog_result ==CONFIRM_ADD_ALL_SKIP_ADDING : + self .log_signal .emit ("ℹ️ User chose not to add new names to Known.txt for this session.") + else : + self .log_signal .emit (f"ℹ️ Using character filters for link extraction: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}") + + + if manga_mode and not actual_filters_to_use_for_run and not extract_links_only : + msg_box =QMessageBox (self ) + msg_box .setIcon (QMessageBox .Warning ) + msg_box .setWindowTitle ("Manga Mode Filter Warning") + msg_box .setText ( + "Manga Mode is enabled, but 'Filter by Character(s)' is empty.\n\n" + "For best results (correct file naming and folder organization if subfolders are on), " + "please enter the Manga/Series title into the filter field.\n\n" + "Proceed without a filter (names might be generic, folder might be less specific)?" + ) + proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole ) + cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole ) + msg_box .exec_ () + if msg_box .clickedButton ()==cancel_button : + self .log_signal .emit ("❌ Download cancelled due to Manga Mode filter warning.") + return False + else : + self .log_signal .emit ("⚠️ Proceeding with Manga Mode without a specific title filter.") + self .dynamic_character_filter_holder .set_filters (actual_filters_to_use_for_run ) + + + creator_folder_ignore_words_for_run =None + character_filters_are_empty =not actual_filters_to_use_for_run + if is_full_creator_download and character_filters_are_empty : + creator_folder_ignore_words_for_run =CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS + log_messages .append (f" Creator Download (No Char Filter): Applying default folder name ignore list ({len (creator_folder_ignore_words_for_run )} words).") + + custom_folder_name_cleaned =None + if use_subfolders and post_id_from_url and self .custom_folder_widget and self .custom_folder_widget .isVisible ()and not extract_links_only : + 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 }' (resulted in empty string after cleaning).") + + + self .main_log_output .clear () + if extract_links_only :self .main_log_output .append ("🔗 Extracting Links..."); + elif backend_filter_mode =='archive':self .main_log_output .append ("📦 Downloading Archives Only...") + + if self .external_log_output :self .external_log_output .clear () + if self .show_external_links and not extract_links_only and backend_filter_mode !='archive': + self .external_log_output .append ("🔗 External Links Found:") + + self .file_progress_label .setText ("");self .cancellation_event .clear ();self .active_futures =[] + self .total_posts_to_process =0 ;self .processed_posts_count =0 ;self .download_counter =0 ;self .skip_counter =0 + self .progress_label .setText (self ._tr ("progress_initializing_text","Progress: Initializing...")) + + self .retryable_failed_files_info .clear () + self .permanently_failed_files_for_dialog .clear () + self._update_error_button_count() + + manga_date_file_counter_ref_for_thread =None + if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED and not extract_links_only : + manga_date_file_counter_ref_for_thread =None + self .log_signal .emit (f"ℹ️ Manga Date Mode: File counter will be initialized by the download thread.") + + manga_global_file_counter_ref_for_thread =None + if manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not extract_links_only : + manga_global_file_counter_ref_for_thread =None + self .log_signal .emit (f"ℹ️ Manga Title+GlobalNum Mode: File counter will be initialized by the download thread (starts at 1).") + + effective_num_post_workers =1 + + effective_num_file_threads_per_worker =1 + + if post_id_from_url : + if use_multithreading_enabled_by_checkbox : + effective_num_file_threads_per_worker =max (1 ,min (num_threads_from_gui ,MAX_FILE_THREADS_PER_POST_OR_WORKER )) + else : + if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED : + effective_num_post_workers =1 + elif manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : + effective_num_post_workers =1 + effective_num_file_threads_per_worker =1 + elif use_multithreading_enabled_by_checkbox : + effective_num_post_workers =max (1 ,min (num_threads_from_gui ,MAX_THREADS )) + effective_num_file_threads_per_worker =1 + + if not extract_links_only :log_messages .append (f" Save Location: {effective_output_dir_for_run }") + + if post_id_from_url : + log_messages .append (f" Mode: Single Post") + log_messages .append (f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker } concurrent file(s)") + else : + log_messages .append (f" Mode: Creator Feed") + log_messages .append (f" Post Processing: {'Multi-threaded ('+str (effective_num_post_workers )+' workers)'if effective_num_post_workers >1 else 'Single-threaded (1 worker)'}") + log_messages .append (f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker } concurrent file(s)") + pr_log ="All" + if start_page or end_page : + 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 () + + if manga_mode : + log_messages .append (f" Page Range: {pr_log if pr_log else 'All'} (Manga Mode - Oldest Posts Processed First within range)") + else : + log_messages .append (f" Page Range: {pr_log if pr_log else 'All'}") + + + if not extract_links_only : + log_messages .append (f" Subfolders: {'Enabled'if use_subfolders else 'Disabled'}") + if use_subfolders and self.use_subfolder_per_post_checkbox.isChecked(): + use_date_prefix = self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False + log_messages.append(f" ↳ Date Prefix for Post Subfolders: {'Enabled' if use_date_prefix else 'Disabled'}") + if use_subfolders : + if custom_folder_name_cleaned :log_messages .append (f" Custom Folder (Post): '{custom_folder_name_cleaned }'") + if actual_filters_to_use_for_run : + log_messages .append (f" Character Filters: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}") + log_messages .append (f" ↳ Char Filter Scope: {current_char_filter_scope .capitalize ()}") + elif use_subfolders : + log_messages .append (f" Folder Naming: Automatic (based on title/known names)") + + + keep_duplicates = self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False + log_messages.extend([ + f" File Type Filter: {user_selected_filter_text} (Backend processing as: {backend_filter_mode})", + f" Keep In-Post Duplicates: {'Enabled' if keep_duplicates else 'Disabled'}", + f" Skip Archives: {'.zip' if effective_skip_zip else ''}{', ' if effective_skip_zip and effective_skip_rar else ''}{'.rar' if effective_skip_rar else ''}{'None (Archive Mode)' if backend_filter_mode == 'archive' else ('None' if not (effective_skip_zip or effective_skip_rar) else '')}", + f" Skip Words Scope: {current_skip_words_scope .capitalize ()}", + f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}", + f" Compress Images: {'Enabled'if compress_images else 'Disabled'}", + f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}" + ]) + log_messages .append (f" Scan Post Content for Images: {'Enabled'if scan_content_for_images else 'Disabled'}") + else : + log_messages .append (f" Mode: Extracting Links Only") + + log_messages .append (f" Show External Links: {'Enabled'if self .show_external_links and not extract_links_only and backend_filter_mode !='archive'else 'Disabled'}") + + if manga_mode : + log_messages .append (f" Manga Mode (File Renaming by Post Title): Enabled") + log_messages .append (f" ↳ Manga Filename Style: {'Post Title Based'if self .manga_filename_style ==STYLE_POST_TITLE else 'Original File Name'}") + if actual_filters_to_use_for_run : + log_messages .append (f" ↳ Manga Character Filter (for naming/folder): {', '.join (item ['name']for item in actual_filters_to_use_for_run )}") + log_messages .append (f" ↳ Manga Duplicates: Will be renamed with numeric suffix if names clash (e.g., _1, _2).") + + log_messages .append (f" Use Cookie ('cookies.txt'): {'Enabled'if use_cookie_from_checkbox else 'Disabled'}") + if use_cookie_from_checkbox and cookie_text_from_input : + log_messages .append (f" ↳ Cookie Text Provided: Yes (length: {len (cookie_text_from_input )})") + elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend : + log_messages .append (f" ↳ Cookie File Selected: {os .path .basename (selected_cookie_file_path_for_backend )}") + should_use_multithreading_for_posts =use_multithreading_enabled_by_checkbox and not post_id_from_url + if manga_mode and (self .manga_filename_style ==STYLE_DATE_BASED or self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING )and not post_id_from_url : + enforced_by_style ="Date Mode"if self .manga_filename_style ==STYLE_DATE_BASED else "Title+GlobalNum Mode" + should_use_multithreading_for_posts =False + log_messages .append (f" Threading: Single-threaded (posts) - Enforced by Manga {enforced_by_style } (Actual workers: {effective_num_post_workers if effective_num_post_workers >1 else 1 })") + else : + log_messages .append (f" Threading: {'Multi-threaded (posts)'if should_use_multithreading_for_posts else 'Single-threaded (posts)'}") + if should_use_multithreading_for_posts : + log_messages .append (f" Number of Post Worker Threads: {effective_num_post_workers }") + log_messages .append ("="*40 ) + for msg in log_messages :self .log_signal .emit (msg ) + + self .set_ui_enabled (False ) + + + from src.config.constants import FOLDER_NAME_STOP_WORDS + + + args_template ={ + 'api_url_input':api_url , + 'download_root':effective_output_dir_for_run , + 'output_dir':effective_output_dir_for_run , + 'known_names':list (KNOWN_NAMES ), + 'known_names_copy':list (KNOWN_NAMES ), + 'filter_character_list':actual_filters_to_use_for_run , + 'filter_mode':backend_filter_mode , + 'skip_zip':effective_skip_zip , + 'skip_rar':effective_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 , + 'downloaded_files_lock':self .downloaded_files_lock , + 'downloaded_file_hashes':self .downloaded_file_hashes , + 'downloaded_file_hashes_lock':self .downloaded_file_hashes_lock , + 'skip_words_list':skip_words_list , + 'skip_words_scope':current_skip_words_scope , + 'remove_from_filename_words_list':remove_from_filename_words_list , + 'char_filter_scope':current_char_filter_scope , + 'show_external_links':self .show_external_links , + 'extract_links_only':extract_links_only , + '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':FOLDER_NAME_STOP_WORDS , + 'cancellation_event':self .cancellation_event , + 'manga_date_prefix':manga_date_prefix_text , + 'dynamic_character_filter_holder':self .dynamic_character_filter_holder , + 'pause_event':self .pause_event , + 'scan_content_for_images':scan_content_for_images , + 'manga_filename_style':self .manga_filename_style , + 'num_file_threads_for_worker':effective_num_file_threads_per_worker , + 'manga_date_file_counter_ref':manga_date_file_counter_ref_for_thread , + 'allow_multipart_download':allow_multipart , + 'cookie_text':cookie_text_from_input , + 'selected_cookie_file':selected_cookie_file_path_for_backend , + 'manga_global_file_counter_ref':manga_global_file_counter_ref_for_thread , + 'app_base_dir':app_base_dir_for_cookies , + 'use_cookie':use_cookie_for_this_run , + 'session_file_path': self.session_file_path, + 'session_lock': self.session_lock, + 'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run , + 'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False, + 'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False, + } + + args_template ['override_output_dir']=override_output_dir + try : + if should_use_multithreading_for_posts : + self .log_signal .emit (f" Initializing multi-threaded {current_mode_log_text .lower ()} with {effective_num_post_workers } post workers...") + args_template ['emitter']=self .worker_to_gui_queue + self .start_multi_threaded_download (num_post_workers =effective_num_post_workers ,**args_template ) + else : + self .log_signal .emit (f" Initializing single-threaded {'link extraction'if extract_links_only else 'download'}...") + 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','pause_event','remove_from_filename_words_list', + 'downloaded_files_lock','downloaded_file_hashes_lock','dynamic_character_filter_holder', 'session_file_path', + 'session_lock', + 'skip_words_list','skip_words_scope','char_filter_scope', + 'show_external_links','extract_links_only','num_file_threads_for_worker', + 'start_page','end_page','target_post_id_from_initial_url', + 'manga_date_file_counter_ref', + 'manga_global_file_counter_ref','manga_date_prefix', + 'manga_mode_active','unwanted_keywords','manga_filename_style','scan_content_for_images', + 'allow_multipart_download','use_cookie','cookie_text','app_base_dir','selected_cookie_file','override_output_dir' + ] + args_template ['skip_current_file_flag']=None + single_thread_args ={key :args_template [key ]for key in dt_expected_keys if key in args_template } + self .start_single_threaded_download (**single_thread_args ) + except Exception as e : + self._update_button_states_and_connections() # Re-enable UI if start fails + self .log_signal .emit (f"❌ CRITICAL ERROR preparing download: {e }\n{traceback .format_exc ()}") + QMessageBox .critical (self ,"Start Error",f"Failed to start process:\n{e }") + self .download_finished (0 ,0 ,False ,[]) + if self .pause_event :self .pause_event .clear () + self .is_paused =False + return True + + def restore_download(self): + """Initiates the download restoration process.""" + if self._is_download_active(): + QMessageBox.warning(self, "Busy", "A download is already in progress.") + return + + if not self.interrupted_session_data: + self.log_signal.emit("❌ No session data to restore.") + self._clear_session_and_reset_ui() + return + + self.log_signal.emit("🔄 Restoring download session...") + # The main start_download function now handles the restore logic + self.is_restore_pending = True # Set state to indicate restore is in progress + self.start_download(is_restore=True) + + def start_single_threaded_download (self ,**kwargs ): + global BackendDownloadThread + try : + self .download_thread =BackendDownloadThread (**kwargs ) + if self .pause_event :self .pause_event .clear () + self .is_paused =False + 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'):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 .download_finished ) + if hasattr (self .download_thread ,'receive_add_character_result'):self .character_prompt_response_signal .connect (self .download_thread .receive_add_character_result ) + if hasattr (self .download_thread ,'external_link_signal'):self .download_thread .external_link_signal .connect (self .handle_external_link_signal ) + if hasattr (self .download_thread ,'file_progress_signal'):self .download_thread .file_progress_signal .connect (self .update_file_progress_display ) + if hasattr (self .download_thread ,'missed_character_post_signal'): + self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post ) + if hasattr (self .download_thread ,'retryable_file_failed_signal'): + + if hasattr (self .download_thread ,'file_successfully_downloaded_signal'): + self .download_thread .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded ) + if hasattr (self .download_thread ,'post_processed_for_history_signal'): + self .download_thread .post_processed_for_history_signal .connect (self ._add_to_history_candidates ) + self .download_thread .retryable_file_failed_signal .connect (self ._handle_retryable_file_failure ) + if hasattr (self .download_thread ,'permanent_file_failed_signal'): + self .download_thread .permanent_file_failed_signal .connect (self ._handle_permanent_file_failure_from_thread ) + self .download_thread .start () + self .log_signal .emit ("✅ Single download thread (for posts) started.") + self._update_button_states_and_connections() # Update buttons after thread starts + 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 }") + if self .pause_event :self .pause_event .clear () + self .is_paused =False + + def _show_error_files_dialog (self ): + """Shows the dialog with files that were skipped due to errors.""" + if not self .permanently_failed_files_for_dialog : + QMessageBox .information ( + self , + self ._tr ("no_errors_logged_title","No Errors Logged"), + self ._tr ("no_errors_logged_message","No files were recorded as skipped due to errors in the last session or after retries.")) + return + dialog =ErrorFilesDialog (self .permanently_failed_files_for_dialog ,self ,self ) + dialog .retry_selected_signal .connect (self ._handle_retry_from_error_dialog ) + dialog .exec_ () + def _handle_retry_from_error_dialog (self ,selected_files_to_retry ): + self ._start_failed_files_retry_session (files_to_retry_list =selected_files_to_retry ) + self._update_error_button_count() + + def _handle_retryable_file_failure (self ,list_of_retry_details ): + """Appends details of files that failed but might be retryable later.""" + if list_of_retry_details : + self .retryable_failed_files_info .extend (list_of_retry_details ) + + def _handle_permanent_file_failure_from_thread (self ,list_of_permanent_failure_details ): + """Handles permanently failed files signaled by the single BackendDownloadThread.""" + if list_of_permanent_failure_details : + self .permanently_failed_files_for_dialog .extend (list_of_permanent_failure_details ) + self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.") + self._update_error_button_count() + + def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ): + """Helper to prepare and submit a single post processing task to the thread pool.""" + global PostProcessorWorker + if not isinstance (post_data_item ,dict ): + self .log_signal .emit (f"⚠️ Skipping invalid post data item (not a dict): {type (post_data_item )}"); + return False + + 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_for_each_worker + elif key =='emitter':worker_init_args [key ]=emitter_for_worker + elif key in worker_args_template :worker_init_args [key ]=worker_args_template [key ] + elif key in ppw_optional_keys_with_defaults :pass + else :missing_keys .append (key ) + + if missing_keys : + self .log_signal .emit (f"❌ CRITICAL ERROR: Missing keys for PostProcessorWorker: {', '.join (missing_keys )}"); + self .cancellation_event .set () + return False + + try : + worker_instance =PostProcessorWorker (**worker_init_args ) + if self .thread_pool : + future =self .thread_pool .submit (worker_instance .process ) + future .add_done_callback (self ._handle_future_result ) + self .active_futures .append (future ) + return True + else : + self .log_signal .emit ("⚠️ Thread pool not available. Cannot submit task."); + self .cancellation_event .set () + return False + except TypeError as te : + self .log_signal .emit (f"❌ TypeError creating PostProcessorWorker: {te }\n Passed Args: [{', '.join (sorted (worker_init_args .keys ()))}]\n{traceback .format_exc (limit =5 )}") + self .cancellation_event .set () + return False + except RuntimeError : + self .log_signal .emit (f"⚠️ RuntimeError submitting task (pool likely shutting down).") + self .cancellation_event .set () + return False + except Exception as e : + self .log_signal .emit (f"❌ Error submitting post {post_data_item .get ('id','N/A')} to worker: {e }") + self .cancellation_event .set () + return False + + def _load_ui_from_settings_dict(self, settings: dict): + """Populates the UI with values from a settings dictionary.""" + # Text inputs + self.link_input.setText(settings.get('api_url', '')) + self.dir_input.setText(settings.get('output_dir', '')) + self.character_input.setText(settings.get('character_filter_text', '')) + self.skip_words_input.setText(settings.get('skip_words_text', '')) + self.remove_from_filename_input.setText(settings.get('remove_words_text', '')) + self.custom_folder_input.setText(settings.get('custom_folder_name', '')) + self.cookie_text_input.setText(settings.get('cookie_text', '')) + if hasattr(self, 'manga_date_prefix_input'): + self.manga_date_prefix_input.setText(settings.get('manga_date_prefix', '')) + + # Numeric inputs + self.thread_count_input.setText(str(settings.get('num_threads', 4))) + self.start_page_input.setText(str(settings.get('start_page', '')) if settings.get('start_page') is not None else '') + self.end_page_input.setText(str(settings.get('end_page', '')) if settings.get('end_page') is not None else '') + + # Checkboxes + for checkbox_name, key in self.get_checkbox_map().items(): + checkbox = getattr(self, checkbox_name, None) + if checkbox: + checkbox.setChecked(settings.get(key, False)) + + # Radio buttons + if settings.get('only_links'): self.radio_only_links.setChecked(True) + else: + filter_mode = settings.get('filter_mode', 'all') + if filter_mode == 'image': self.radio_images.setChecked(True) + elif filter_mode == 'video': self.radio_videos.setChecked(True) + elif filter_mode == 'archive': self.radio_only_archives.setChecked(True) + elif filter_mode == 'audio' and hasattr(self, 'radio_only_audio'): self.radio_only_audio.setChecked(True) + else: self.radio_all.setChecked(True) + + # Toggle button states + self.skip_words_scope = settings.get('skip_words_scope', SKIP_SCOPE_POSTS) + self.char_filter_scope = settings.get('char_filter_scope', CHAR_SCOPE_TITLE) + self.manga_filename_style = settings.get('manga_filename_style', STYLE_POST_TITLE) + self.allow_multipart_download_setting = settings.get('allow_multipart_download', False) + + # Update button texts after setting states + self._update_skip_scope_button_text() + self._update_char_filter_scope_button_text() + self._update_manga_filename_style_button_text() + self._update_multipart_toggle_button_text() + + def start_multi_threaded_download (self ,num_post_workers ,**kwargs ): + global PostProcessorWorker + if self .thread_pool is None : + if self .pause_event :self .pause_event .clear () + self .is_paused =False + self .thread_pool =ThreadPoolExecutor (max_workers =num_post_workers ,thread_name_prefix ='PostWorker_') + + self .active_futures =[] + self .processed_posts_count =0 ;self .total_posts_to_process =0 ;self .download_counter =0 ;self .skip_counter =0 + self .all_kept_original_filenames =[] + self .is_fetcher_thread_running =True + + fetcher_thread =threading .Thread ( + target =self ._fetch_and_queue_posts , + args =(kwargs ['api_url_input'],kwargs ,num_post_workers ), + daemon =True , + name ="PostFetcher" + ) + fetcher_thread .start () + self .log_signal .emit (f"✅ Post fetcher thread started. {num_post_workers } post worker threads initializing...") + self._update_button_states_and_connections() # Update buttons after fetcher thread starts + + def _fetch_and_queue_posts (self ,api_url_input_for_fetcher ,worker_args_template ,num_post_workers ): + global PostProcessorWorker ,download_from_api + all_posts_data =[] + fetch_error_occurred =False + manga_mode_active_for_fetch =worker_args_template .get ('manga_mode_active',False ) + emitter_for_worker =worker_args_template .get ('emitter') + + is_restore = self.interrupted_session_data is not None + if is_restore: + all_posts_data = self.interrupted_session_data['download_state']['all_posts_data'] + processed_ids = set(self.interrupted_session_data['download_state']['processed_post_ids']) + posts_to_process = [p for p in all_posts_data if p.get('id') not in processed_ids] + self.log_signal.emit(f"Restoring session. {len(posts_to_process)} posts remaining out of {len(all_posts_data)}.") + self.total_posts_to_process = len(all_posts_data) + self.processed_posts_count = len(processed_ids) + self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) + + # Re-assign all_posts_data to only what needs processing + all_posts_data = posts_to_process + + if not emitter_for_worker : + self .log_signal .emit ("❌ CRITICAL ERROR: Emitter (queue) missing for worker in _fetch_and_queue_posts."); + self .finished_signal .emit (0 ,0 ,True ,[]); + return + + try: + self.log_signal.emit(" Fetching post data from API (this may take a moment for large feeds)...") + if not is_restore: # Only fetch new data if not restoring + post_generator = download_from_api( + api_url_input_for_fetcher, + logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), + 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, + pause_event=worker_args_template.get('pause_event'), + use_cookie=worker_args_template.get('use_cookie'), + cookie_text=worker_args_template.get('cookie_text'), + selected_cookie_file=worker_args_template.get('selected_cookie_file'), + app_base_dir=worker_args_template.get('app_base_dir'), + manga_filename_style_for_sort_check=( + worker_args_template.get('manga_filename_style') + if manga_mode_active_for_fetch + else None + ) + ) + + for posts_batch in post_generator: + if self.cancellation_event.is_set(): + 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) + 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: + 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}") + + # Get a clean, serializable dictionary of UI settings + output_dir_for_session = worker_args_template.get('output_dir', self.dir_input.text().strip()) + ui_settings_for_session = self._get_current_ui_settings_as_dict( + api_url_override=api_url_input_for_fetcher, + output_dir_override=output_dir_for_session + ) + + # Save initial session state + session_data = { + "timestamp": datetime.datetime.now().isoformat(), + "ui_settings": ui_settings_for_session, + "download_state": { + "all_posts_data": all_posts_data, + "processed_post_ids": [] + } + } + self._save_session_file(session_data) + + # From here, all_posts_data is the list of posts to process (either new or restored) + unique_posts_dict ={} + for post in all_posts_data : + post_id =post .get ('id') + if post_id is not None : + if post_id not in unique_posts_dict : + unique_posts_dict [post_id ]=post + else : + self .log_signal .emit (f"⚠️ Skipping post with no ID: {post .get ('title','Untitled')}") + + posts_to_process_final = list(unique_posts_dict.values()) + + if not is_restore: + self.total_posts_to_process = len(posts_to_process_final) + self.log_signal.emit(f" Processed {len(posts_to_process_final)} unique posts after de-duplication.") + if len(posts_to_process_final) < len(all_posts_data): + self.log_signal.emit(f" Note: {len(all_posts_data) - len(posts_to_process_final)} duplicate post IDs were removed.") + all_posts_data = posts_to_process_final + + except TypeError as te : + self .log_signal .emit (f"❌ TypeError calling download_from_api: {te }\n Check 'downloader_utils.py' signature.\n{traceback .format_exc (limit =2 )}");fetch_error_occurred =True + except RuntimeError as re_err : + self .log_signal .emit (f"ℹ️ Post fetching runtime error (likely cancellation or API issue): {re_err }");fetch_error_occurred =True + except Exception as e : + self .log_signal .emit (f"❌ Error during post fetching: {e }\n{traceback .format_exc (limit =2 )}");fetch_error_occurred =True + + finally : + self .is_fetcher_thread_running =False + self .log_signal .emit (f"ℹ️ Post fetcher thread (_fetch_and_queue_posts) has completed its task. is_fetcher_thread_running set to False.") + + 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 (),self .all_kept_original_filenames ) + if self .thread_pool :self .thread_pool .shutdown (wait =False ,cancel_futures =True );self .thread_pool =None + return + + if not all_posts_data: + self .log_signal .emit ("😕 No posts found or fetched to process.") + self .finished_signal .emit (0 ,0 ,False ,[]) + return + + self .log_signal .emit (f" Preparing to submit {self .total_posts_to_process } post processing tasks to thread pool...") + if not is_restore: + self.processed_posts_count = 0 + self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) + + num_file_dl_threads_for_each_worker =worker_args_template .get ('num_file_threads_for_worker',1 ) + + + 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', + 'emitter', + 'pause_event', + 'download_thumbnails', + 'service', + 'user_id', + 'api_url_input', + 'cancellation_event', + 'downloaded_files', + 'downloaded_file_hashes', + 'downloaded_files_lock', + 'downloaded_file_hashes_lock', + 'remove_from_filename_words_list', + 'dynamic_character_filter_holder', + 'skip_words_list', + 'skip_words_scope', + 'char_filter_scope', + 'show_external_links', + 'extract_links_only', + 'allow_multipart_download', + 'use_cookie', + 'cookie_text', + 'app_base_dir', + 'selected_cookie_file', + 'override_output_dir', + 'num_file_threads', + 'skip_current_file_flag', + 'manga_date_file_counter_ref', + 'scan_content_for_images', + 'manga_mode_active', + 'manga_filename_style', + 'manga_date_prefix', + 'use_date_prefix_for_subfolder', + 'keep_in_post_duplicates', + 'manga_global_file_counter_ref', + 'creator_download_folder_ignore_words', + 'session_file_path', + 'session_lock' + ] + + ppw_optional_keys_with_defaults ={ + 'skip_words_list','skip_words_scope','char_filter_scope','remove_from_filename_words_list', + 'show_external_links','extract_links_only','duplicate_file_mode', + 'num_file_threads','skip_current_file_flag','manga_mode_active','manga_filename_style','manga_date_prefix', + 'manga_date_file_counter_ref','use_cookie','cookie_text','app_base_dir','selected_cookie_file' + } + if num_post_workers >POST_WORKER_BATCH_THRESHOLD and self .total_posts_to_process >POST_WORKER_NUM_BATCHES : + self .log_signal .emit (f" High thread count ({num_post_workers }) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES } parts.") + + import math + tasks_submitted_in_batch_segment =0 + batch_size =math .ceil (self .total_posts_to_process /POST_WORKER_NUM_BATCHES ) + submitted_count_in_batching =0 + + for batch_num in range (POST_WORKER_NUM_BATCHES ): + if self .cancellation_event .is_set ():break + + if self .pause_event and self .pause_event .is_set (): + self .log_signal .emit (f" [Fetcher] Batch submission paused before batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }...") + while self .pause_event .is_set (): + if self .cancellation_event .is_set (): + self .log_signal .emit (" [Fetcher] Batch submission cancelled while paused.") + break + time .sleep (0.5 ) + if self .cancellation_event .is_set ():break + if not self .cancellation_event .is_set (): + self .log_signal .emit (f" [Fetcher] Batch submission resumed. Processing batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }.") + + start_index =batch_num *batch_size + end_index =min ((batch_num +1 )*batch_size ,self .total_posts_to_process ) + current_batch_posts =all_posts_data [start_index :end_index ] + + if not current_batch_posts :continue + + self .log_signal .emit (f" Submitting batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES } ({len (current_batch_posts )} posts) to pool...") + for post_data_item in current_batch_posts : + if self .cancellation_event .is_set ():break + success =self ._submit_post_to_worker_pool (post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ) + if success : + submitted_count_in_batching +=1 + tasks_submitted_in_batch_segment +=1 + if tasks_submitted_in_batch_segment %10 ==0 : + time .sleep (0.005 ) + tasks_submitted_in_batch_segment =0 + elif self .cancellation_event .is_set (): + break + + if self .cancellation_event .is_set ():break + + if batch_num 0 and self .processed_posts_count >=self .total_posts_to_process : + if all (f .done ()for f in self .active_futures ): + QApplication .processEvents () + 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 (),self .all_kept_original_filenames ) + + def _add_to_history_candidates (self ,history_data ): + """Adds processed post data to the history candidates list.""" + if history_data and len (self .download_history_candidates )<8 : + history_data ['download_date_timestamp']=time .time () + creator_key =(history_data .get ('service','').lower (),str (history_data .get ('user_id',''))) + history_data ['creator_name']=self .creator_name_cache .get (creator_key ,history_data .get ('user_id','Unknown')) + self .download_history_candidates .append (history_data ) + + def _finalize_download_history (self ): + """Processes candidates and selects the final 3 history entries. + Only updates final_download_history_entries if new candidates are available. + """ + if not self .download_history_candidates : + + + self .log_signal .emit ("ℹ️ No new history candidates from this session. Preserving existing history.") + + + self .download_history_candidates .clear () + return + + candidates =list (self .download_history_candidates ) + now =datetime .datetime .now (datetime .timezone .utc ) + + def get_sort_key (entry ): + upload_date_str =entry .get ('upload_date_str') + if not upload_date_str : + return datetime .timedelta .max + try : + + upload_dt =datetime .datetime .fromisoformat (upload_date_str .replace ('Z','+00:00')) + if upload_dt .tzinfo is None : + upload_dt =upload_dt .replace (tzinfo =datetime .timezone .utc ) + return abs (now -upload_dt ) + except ValueError : + return datetime .timedelta .max + + candidates .sort (key =get_sort_key ) + self .final_download_history_entries =candidates [:3 ] + self .log_signal .emit (f"ℹ️ Finalized download history: {len (self .final_download_history_entries )} entries selected.") + self .download_history_candidates .clear () + + + self ._save_persistent_history () + + def _get_configurable_widgets_on_pause (self ): + """Returns a list of widgets that should be re-enabled when paused.""" + return [ + self .dir_input ,self .dir_button , + self .character_input ,self .char_filter_scope_toggle_button , + self .skip_words_input ,self .skip_scope_toggle_button , + self .remove_from_filename_input , + self .radio_all ,self .radio_images ,self .radio_videos , + self .radio_only_archives ,self .radio_only_links , + self .skip_zip_checkbox ,self .skip_rar_checkbox , + self .download_thumbnails_checkbox ,self .compress_images_checkbox , + self .use_subfolders_checkbox ,self .use_subfolder_per_post_checkbox , + self .manga_mode_checkbox , + self .manga_rename_toggle_button , + self .cookie_browse_button , + self .favorite_mode_checkbox , + self .multipart_toggle_button , + self .cookie_text_input , + self .scan_content_images_checkbox , + self .use_cookie_checkbox , + self .external_links_checkbox + ] + + def set_ui_enabled (self ,enabled ): + all_potentially_toggleable_widgets =[ + self .link_input ,self .dir_input ,self .dir_button , + self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input , + self .character_input ,self .char_filter_scope_toggle_button ,self .character_filter_widget , + self .filters_and_custom_folder_container_widget , + self .custom_folder_label ,self .custom_folder_input , + self .skip_words_input ,self .skip_scope_toggle_button ,self .remove_from_filename_input , + self .radio_all ,self .radio_images ,self .radio_videos ,self .radio_only_archives ,self .radio_only_links , + self .skip_zip_checkbox ,self .skip_rar_checkbox ,self .download_thumbnails_checkbox ,self .compress_images_checkbox , + self .use_subfolders_checkbox ,self .use_subfolder_per_post_checkbox ,self .scan_content_images_checkbox , + self .use_multithreading_checkbox ,self .thread_count_input ,self .thread_count_label , + self .favorite_mode_checkbox , + self .external_links_checkbox ,self .manga_mode_checkbox ,self .manga_rename_toggle_button ,self .use_cookie_checkbox ,self .cookie_text_input ,self .cookie_browse_button , + self .multipart_toggle_button ,self .radio_only_audio , + self .character_search_input ,self .new_char_input ,self .add_char_button ,self .add_to_filter_button ,self .delete_char_button , + self .reset_button + ] + + widgets_to_enable_on_pause =self ._get_configurable_widgets_on_pause () + is_fav_mode_active =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False + download_is_active_or_paused =not enabled + + if not enabled : + if self .bottom_action_buttons_stack : + self .bottom_action_buttons_stack .setCurrentIndex (0 ) + + if self .external_link_download_thread and self .external_link_download_thread .isRunning (): + self .log_signal .emit ("ℹ️ Cancelling active Mega download due to UI state change.") + self .external_link_download_thread .cancel () + else : + pass + + + for widget in all_potentially_toggleable_widgets : + if not widget :continue + + + if widget is self .favorite_mode_artists_button or widget is self .favorite_mode_posts_button :continue + elif self .is_paused and widget in widgets_to_enable_on_pause : + widget .setEnabled (True ) + elif widget is self .favorite_mode_checkbox : + widget .setEnabled (enabled ) + elif widget is self .use_cookie_checkbox and is_fav_mode_active : + widget .setEnabled (False ) + elif widget is self .use_cookie_checkbox and self .is_paused and widget in widgets_to_enable_on_pause : + widget .setEnabled (True ) + else : + widget .setEnabled (enabled ) + + if self .link_input : + self .link_input .setEnabled (enabled and not is_fav_mode_active ) + + + + if not enabled : + if self .favorite_mode_artists_button : + self .favorite_mode_artists_button .setEnabled (False ) + if self .favorite_mode_posts_button : + self .favorite_mode_posts_button .setEnabled (False ) + + if self .download_btn : + self .download_btn .setEnabled (enabled and not is_fav_mode_active ) + + + if self .external_links_checkbox : + is_only_links =self .radio_only_links and self .radio_only_links .isChecked () + is_only_archives =self .radio_only_archives and self .radio_only_archives .isChecked () + is_only_audio =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked () + can_enable_ext_links =enabled and not is_only_links and not is_only_archives and not is_only_audio + self .external_links_checkbox .setEnabled (can_enable_ext_links ) + if self .is_paused and not is_only_links and not is_only_archives and not is_only_audio : + self .external_links_checkbox .setEnabled (True ) + if hasattr (self ,'use_cookie_checkbox'): + self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()) + + if self .log_verbosity_toggle_button :self .log_verbosity_toggle_button .setEnabled (True ) + + multithreading_currently_on =self .use_multithreading_checkbox .isChecked () + if self .thread_count_input :self .thread_count_input .setEnabled (enabled and multithreading_currently_on ) + if self .thread_count_label :self .thread_count_label .setEnabled (enabled and multithreading_currently_on ) + + subfolders_currently_on =self .use_subfolders_checkbox .isChecked () + if self .use_subfolder_per_post_checkbox : + self .use_subfolder_per_post_checkbox .setEnabled (enabled or (self .is_paused and self .use_subfolder_per_post_checkbox in widgets_to_enable_on_pause )) + if self .cancel_btn :self .cancel_btn .setEnabled (download_is_active_or_paused ) + if self .pause_btn : + self .pause_btn .setEnabled (download_is_active_or_paused ) + if download_is_active_or_paused : + self .pause_btn .setText (self ._tr ("resume_download_button_text","▶️ Resume Download")if self .is_paused else self ._tr ("pause_download_button_text","⏸️ Pause Download")) + self .pause_btn .setToolTip (self ._tr ("resume_download_button_tooltip","Click to resume the download.")if self .is_paused else self ._tr ("pause_download_button_tooltip","Click to pause the download.")) + else : + self .pause_btn .setText (self ._tr ("pause_download_button_text","⏸️ Pause Download")) + self .pause_btn .setToolTip (self ._tr ("pause_download_button_tooltip","Click to pause the ongoing download process.")) + self .is_paused =False + if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_button_text","❌ Cancel & Reset UI")) + if enabled : + if self .pause_event :self .pause_event .clear () + if enabled or self .is_paused : + self ._handle_multithreading_toggle (multithreading_currently_on ) + self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False ) + self .update_custom_folder_visibility (self .link_input .text ()) + self .update_page_range_enabled_state () + if self .radio_group and self .radio_group .checkedButton (): + self ._handle_filter_mode_change (self .radio_group .checkedButton (),True ) + self .update_ui_for_subfolders (subfolders_currently_on ) + self ._handle_favorite_mode_toggle (is_fav_mode_active ) + + def _handle_pause_resume_action (self ): + if self ._is_download_active (): + self .is_paused =not self .is_paused + if self .is_paused : + if self .pause_event :self .pause_event .set () + self .log_signal .emit ("ℹ️ Download paused by user. Some settings can now be changed for subsequent operations.") + else : + if self .pause_event :self .pause_event .clear () + self .log_signal .emit ("ℹ️ Download resumed by user.") + self .set_ui_enabled (False ) + + def _perform_soft_ui_reset (self ,preserve_url =None ,preserve_dir =None ): + """Resets UI elements and some state to app defaults, then applies preserved inputs.""" + self .log_signal .emit ("🔄 Performing soft UI reset...") + 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 (); + if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .clear () + self .character_search_input .clear ();self .thread_count_input .setText ("4");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 ); + if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False ) + if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setChecked (False ) + self .external_links_checkbox .setChecked (False ) + if self .manga_mode_checkbox :self .manga_mode_checkbox .setChecked (False ) + if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setChecked (self .use_cookie_setting ) + if not (hasattr (self ,'use_cookie_checkbox')and self .use_cookie_checkbox .isChecked ()): + self .selected_cookie_filepath =None + if hasattr (self ,'cookie_text_input'):self .cookie_text_input .setText (self .cookie_text_setting if self .use_cookie_setting else "") + self .allow_multipart_download_setting =False + self ._update_multipart_toggle_button_text () + + self .skip_words_scope =SKIP_SCOPE_POSTS + self ._update_skip_scope_button_text () + + if hasattr (self ,'manga_date_prefix_input'):self .manga_date_prefix_input .clear () + + self .char_filter_scope =CHAR_SCOPE_TITLE + self ._update_char_filter_scope_button_text () + + self .manga_filename_style =STYLE_POST_TITLE + self ._update_manga_filename_style_button_text () + if preserve_url is not None : + self .link_input .setText (preserve_url ) + if preserve_dir is not None : + self .dir_input .setText (preserve_dir ) + self .external_link_queue .clear ();self .extracted_links_cache =[] + self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None + if self .pause_event :self .pause_event .clear () + self.is_restore_pending = False + self .total_posts_to_process =0 ;self .processed_posts_count =0 + self .download_counter =0 ;self .skip_counter =0 + self .all_kept_original_filenames =[] + self .is_paused =False + self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ()) + + self._update_button_states_and_connections() # Reset button states and connections + self .favorite_download_queue .clear () + self .is_processing_favorites_queue =False + + self .only_links_log_display_mode =LOG_DISPLAY_LINKS + + if hasattr (self ,'link_input'): + if self .download_extracted_links_button : + self .download_extracted_links_button .setEnabled (False ) + + self .last_link_input_text_for_queue_sync =self .link_input .text () + self .permanently_failed_files_for_dialog .clear () + self .filter_character_list (self .character_search_input .text ()) + self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION + self ._update_favorite_scope_button_text () + + self .set_ui_enabled (True ) + self.interrupted_session_data = None # Clear session data from memory + self .update_custom_folder_visibility (self .link_input .text ()) + self .update_page_range_enabled_state () + self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ) + if hasattr (self ,'favorite_mode_checkbox'): + self ._handle_favorite_mode_toggle (False ) + + self .log_signal .emit ("✅ Soft UI reset complete. Preserved URL and Directory (if provided).") + + def _update_log_display_mode_button_text (self ): + if hasattr (self ,'log_display_mode_toggle_button'): + if self .only_links_log_display_mode ==LOG_DISPLAY_LINKS : + self .log_display_mode_toggle_button .setText (self ._tr ("log_display_mode_links_view_text","🔗 Links View")) + self .log_display_mode_toggle_button .setToolTip ( + "Current View: Extracted Links.\n" + "After Mega download, Mega log is shown THEN links are appended.\n" + "Click to switch to 'Download Progress View'." + ) + else : + self .log_display_mode_toggle_button .setText (self ._tr ("log_display_mode_progress_view_text","⬇️ Progress View")) + self .log_display_mode_toggle_button .setToolTip ( + "Current View: Mega Download Progress.\n" + "After Mega download, ONLY Mega log is shown (links hidden).\n" + "Click to switch to 'Extracted Links View'." + ) + + def _toggle_log_display_mode (self ): + self .only_links_log_display_mode =LOG_DISPLAY_DOWNLOAD_PROGRESS if self .only_links_log_display_mode ==LOG_DISPLAY_LINKS else LOG_DISPLAY_LINKS + self ._update_log_display_mode_button_text () + self ._filter_links_log () + + def cancel_download_button_action (self ): + if not self .cancel_btn .isEnabled ()and not self .cancellation_event .is_set ():self .log_signal .emit ("ℹ️ No active download to cancel or already cancelling.");return + self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...") + + self._clear_session_file() # Clear session file on explicit cancel + if self .external_link_download_thread and self .external_link_download_thread .isRunning (): + self .log_signal .emit (" Cancelling active External Link download thread...") + self .external_link_download_thread .cancel () + + current_url =self .link_input .text () + current_dir =self .dir_input .text () + + self .cancellation_event .set () + self .is_fetcher_thread_running =False + if self .download_thread and self .download_thread .isRunning ():self .download_thread .requestInterruption ();self .log_signal .emit (" Signaled single download thread to interrupt.") + if self .thread_pool : + self .log_signal .emit (" Initiating non-blocking shutdown and cancellation of worker pool tasks...") + self .thread_pool .shutdown (wait =False ,cancel_futures =True ) + self .thread_pool =None + self .active_futures =[] + + self .external_link_queue .clear ();self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None + + self ._perform_soft_ui_reset (preserve_url =current_url ,preserve_dir =current_dir ) + + self .progress_label .setText (f"{self ._tr ('status_cancelled_by_user','Cancelled by user')}. {self ._tr ('ready_for_new_task_text','Ready for new task.')}") + self .file_progress_label .setText ("") + if self .pause_event :self .pause_event .clear () + self .log_signal .emit ("ℹ️ UI reset. Ready for new operation. Background tasks are being terminated.") + self .is_paused =False + if hasattr (self ,'retryable_failed_files_info')and self .retryable_failed_files_info : + self .log_signal .emit (f" Discarding {len (self .retryable_failed_files_info )} pending retryable file(s) due to cancellation.") + self .cancellation_message_logged_this_session =False + self .retryable_failed_files_info .clear () + self .favorite_download_queue .clear () + self .permanently_failed_files_for_dialog .clear () + self .is_processing_favorites_queue =False + self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION + self ._update_favorite_scope_button_text () + if hasattr (self ,'link_input'): + self .last_link_input_text_for_queue_sync =self .link_input .text () + self .cancellation_message_logged_this_session =False + + def _get_domain_for_service (self ,service_name :str )->str : + """Determines the base domain for a given service.""" + if not isinstance (service_name ,str ): + return "kemono.su" + service_lower =service_name .lower () + coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'} + if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']: + return "coomer.su" + return "kemono.su" + + def download_finished (self ,total_downloaded ,total_skipped ,cancelled_by_user ,kept_original_names_list =None ): + if kept_original_names_list is None : + kept_original_names_list =list (self .all_kept_original_filenames )if hasattr (self ,'all_kept_original_filenames')else [] + if kept_original_names_list is None : + kept_original_names_list =[] + + if not cancelled_by_user and not self.retryable_failed_files_info: + self._clear_session_file() + self.interrupted_session_data = None + self.is_restore_pending = False + + self ._finalize_download_history () + status_message =self ._tr ("status_cancelled_by_user","Cancelled by user")if cancelled_by_user else self ._tr ("status_completed","Completed") + if cancelled_by_user and self .retryable_failed_files_info : + self .log_signal .emit (f" Download cancelled, discarding {len (self .retryable_failed_files_info )} file(s) that were pending retry.") + self .retryable_failed_files_info .clear () + + summary_log ="="*40 + summary_log +=f"\n🏁 Download {status_message }!\n Summary: Downloaded Files={total_downloaded }, Skipped Files={total_skipped }\n" + summary_log +="="*40 + self .log_signal .emit (summary_log ) + + if kept_original_names_list : + intro_msg =( + HTML_PREFIX + + "

ℹ️ The following files from multi-file manga posts " + "(after the first file) kept their original names:

" + ) + self .log_signal .emit (intro_msg ) + + html_list_items ="
    " + for name in kept_original_names_list : + html_list_items +=f"
  • {name }
  • " + html_list_items +="
" + + self .log_signal .emit (HTML_PREFIX +html_list_items ) + self .log_signal .emit ("="*40 ) + + 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 .download_finished ) + if hasattr (self .download_thread ,'receive_add_character_result'):self .character_prompt_response_signal .disconnect (self .download_thread .receive_add_character_result ) + 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 ) + if hasattr (self .download_thread ,'missed_character_post_signal'): + self .download_thread .missed_character_post_signal .disconnect (self .handle_missed_character_post ) + if hasattr (self .download_thread ,'retryable_file_failed_signal'): + self .download_thread .retryable_file_failed_signal .disconnect (self ._handle_retryable_file_failure ) + if hasattr (self .download_thread ,'file_successfully_downloaded_signal'): + self .download_thread .file_successfully_downloaded_signal .disconnect (self ._handle_actual_file_downloaded ) + if hasattr (self .download_thread ,'post_processed_for_history_signal'): + self .download_thread .post_processed_for_history_signal .disconnect (self ._add_to_history_candidates ) + except (TypeError ,RuntimeError )as e : + self .log_signal .emit (f"ℹ️ Note during single-thread signal disconnection: {e }") + + if not self .download_thread .isRunning (): + + if self .download_thread : + self .download_thread .deleteLater () + self .download_thread =None + + self .progress_label .setText ( + f"{status_message }: " + f"{total_downloaded } {self ._tr ('files_downloaded_label','downloaded')}, " + f"{total_skipped } {self ._tr ('files_skipped_label','skipped')}." + ) + self .file_progress_label .setText ("") + if not cancelled_by_user :self ._try_process_next_external_link () + + if self .thread_pool : + self .log_signal .emit (" Ensuring worker thread pool is shut down...") + self .thread_pool .shutdown (wait =True ,cancel_futures =True ) + self .thread_pool =None + + self .active_futures =[] + if self .pause_event :self .pause_event .clear () + self .cancel_btn .setEnabled (False ) + self .is_paused =False + if not cancelled_by_user and self .retryable_failed_files_info : + num_failed =len (self .retryable_failed_files_info ) + reply =QMessageBox .question (self ,"Retry Failed Downloads?", + f"{num_failed } file(s) failed with potentially recoverable errors (e.g., IncompleteRead).\n\n" + "Would you like to attempt to download these failed files again?", + QMessageBox .Yes |QMessageBox .No ,QMessageBox .Yes ) + if reply ==QMessageBox .Yes : + self ._start_failed_files_retry_session () + return + else : + self .log_signal .emit ("ℹ️ User chose not to retry failed files.") + self .permanently_failed_files_for_dialog .extend (self .retryable_failed_files_info ) + if self .permanently_failed_files_for_dialog : + self .log_signal .emit (f"🆘 Error button enabled. {len (self .permanently_failed_files_for_dialog )} file(s) can be viewed.") + self .cancellation_message_logged_this_session =False + self .retryable_failed_files_info .clear () + + self .is_fetcher_thread_running =False + + if self .is_processing_favorites_queue : + if not self .favorite_download_queue : + self .is_processing_favorites_queue =False + self .log_signal .emit (f"✅ All {self .current_processing_favorite_item_info .get ('type','item')} downloads from favorite queue have been processed.") + self .set_ui_enabled (not self ._is_download_active ()) + else : + self ._process_next_favorite_download () + else : + self .set_ui_enabled (True ) + self .cancellation_message_logged_this_session =False + + def _handle_thumbnail_mode_change (self ,thumbnails_checked ): + """Handles UI changes when 'Download Thumbnails Only' is toggled.""" + if not hasattr (self ,'scan_content_images_checkbox'): + return + + if thumbnails_checked : + self .scan_content_images_checkbox .setChecked (True ) + self .scan_content_images_checkbox .setEnabled (False ) + self .scan_content_images_checkbox .setToolTip ( + "Automatically enabled and locked because 'Download Thumbnails Only' is active.\n" + "In this mode, only images found by content scanning will be downloaded." + ) + else : + self .scan_content_images_checkbox .setEnabled (True ) + self .scan_content_images_checkbox .setChecked (False ) + self .scan_content_images_checkbox .setToolTip (self ._original_scan_content_tooltip ) + + def _start_failed_files_retry_session (self ,files_to_retry_list =None ): + if files_to_retry_list : + self .files_for_current_retry_session =list (files_to_retry_list ) + self .permanently_failed_files_for_dialog =[f for f in self .permanently_failed_files_for_dialog if f not in files_to_retry_list ] + else : + self .files_for_current_retry_session =list (self .retryable_failed_files_info ) + self .retryable_failed_files_info .clear () + self .log_signal .emit (f"🔄 Starting retry session for {len (self .files_for_current_retry_session )} file(s)...") + self .set_ui_enabled (False ) + if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_retry_button_text","❌ Cancel Retry")) + + + self .active_retry_futures =[] + self .processed_retry_count =0 + self .succeeded_retry_count =0 + self .failed_retry_count_in_session =0 + self .total_files_for_retry =len (self .files_for_current_retry_session ) + self .active_retry_futures_map ={} + + self .progress_label .setText (self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =0 ,total_posts =self .total_files_for_retry ,progress_percent =0.0 ).replace ("posts","files")) + self .cancellation_event .clear () + + num_retry_threads =1 + try : + num_threads_from_gui =int (self .thread_count_input .text ().strip ()) + num_retry_threads =max (1 ,min (num_threads_from_gui ,MAX_FILE_THREADS_PER_POST_OR_WORKER ,self .total_files_for_retry if self .total_files_for_retry >0 else 1 )) + except ValueError : + num_retry_threads =1 + + self .retry_thread_pool =ThreadPoolExecutor (max_workers =num_retry_threads ,thread_name_prefix ='RetryFile_') + common_ppw_args_for_retry ={ + 'download_root':self .dir_input .text ().strip (), + 'known_names':list (KNOWN_NAMES ), + 'emitter':self .worker_to_gui_queue , + 'unwanted_keywords':{'spicy','hd','nsfw','4k','preview','teaser','clip'}, + '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 (), + 'compress_images':self .compress_images_checkbox .isChecked (), + 'download_thumbnails':self .download_thumbnails_checkbox .isChecked (), + 'pause_event':self .pause_event , + 'cancellation_event':self .cancellation_event , + 'downloaded_files':self .downloaded_files , + 'downloaded_file_hashes':self .downloaded_file_hashes , + 'downloaded_files_lock':self .downloaded_files_lock , + 'downloaded_file_hashes_lock':self .downloaded_file_hashes_lock , + 'skip_words_list':[word .strip ().lower ()for word in self .skip_words_input .text ().strip ().split (',')if word .strip ()], + 'skip_words_scope':self .get_skip_words_scope (), + 'char_filter_scope':self .get_char_filter_scope (), + 'remove_from_filename_words_list':[word .strip ()for word in self .remove_from_filename_input .text ().strip ().split (',')if word .strip ()]if hasattr (self ,'remove_from_filename_input')else [], + 'allow_multipart_download':self .allow_multipart_download_setting , + 'filter_character_list':None , + 'dynamic_character_filter_holder':None , + 'target_post_id_from_initial_url':None , + 'custom_folder_name':None , + 'num_file_threads':1 , + 'manga_date_file_counter_ref':None , + } + + for job_details in self .files_for_current_retry_session : + future =self .retry_thread_pool .submit (self ._execute_single_file_retry ,job_details ,common_ppw_args_for_retry ) + future .add_done_callback (self ._handle_retry_future_result ) + self .active_retry_futures_map [future ]=job_details + self .active_retry_futures .append (future ) + + def _execute_single_file_retry (self ,job_details ,common_args ): + """Executes a single file download retry attempt.""" + dummy_post_data ={'id':job_details ['original_post_id_for_log'],'title':job_details ['post_title']} + + ppw_init_args ={ + **common_args , + 'post_data':dummy_post_data , + 'service':job_details .get ('service','unknown_service'), + 'user_id':job_details .get ('user_id','unknown_user'), + 'api_url_input':job_details .get ('api_url_input',''), + 'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ), + 'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ), + 'scan_content_for_images':common_args .get ('scan_content_for_images',False ), + 'use_cookie':common_args .get ('use_cookie',False ), + 'cookie_text':common_args .get ('cookie_text',""), + 'selected_cookie_file':common_args .get ('selected_cookie_file',None ), + 'app_base_dir':common_args .get ('app_base_dir',None ), + } + worker =PostProcessorWorker (**ppw_init_args ) + + dl_count ,skip_count ,filename_saved ,original_kept ,status ,_ =worker ._download_single_file ( + file_info =job_details ['file_info'], + target_folder_path =job_details ['target_folder_path'], + headers =job_details ['headers'], + original_post_id_for_log =job_details ['original_post_id_for_log'], + skip_event =None , + post_title =job_details ['post_title'], + file_index_in_post =job_details ['file_index_in_post'], + num_files_in_this_post =job_details ['num_files_in_this_post'], + forced_filename_override =job_details .get ('forced_filename_override') + ) + + + + is_successful_download =(status ==FILE_DOWNLOAD_STATUS_SUCCESS ) + is_resolved_as_skipped =(status ==FILE_DOWNLOAD_STATUS_SKIPPED ) + + return is_successful_download or is_resolved_as_skipped + + def _handle_retry_future_result (self ,future ): + self .processed_retry_count +=1 + was_successful =False + try : + if future .cancelled (): + self .log_signal .emit (" A retry task was cancelled.") + elif future .exception (): + self .log_signal .emit (f"❌ Retry task worker error: {future .exception ()}") + else : + was_successful =future .result () + job_details =self .active_retry_futures_map .pop (future ,None ) + if was_successful : + self .succeeded_retry_count +=1 + else : + self .failed_retry_count_in_session +=1 + if job_details : + self .permanently_failed_files_for_dialog .append (job_details ) + except Exception as e : + self .log_signal .emit (f"❌ Error in _handle_retry_future_result: {e }") + self .failed_retry_count_in_session +=1 + + progress_percent_retry =(self .processed_retry_count /self .total_files_for_retry *100 )if self .total_files_for_retry >0 else 0 + self .progress_label .setText ( + self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =self .processed_retry_count ,total_posts =self .total_files_for_retry ,progress_percent =progress_percent_retry ).replace ("posts","files")+ + f" ({self ._tr ('succeeded_text','Succeeded')}: {self .succeeded_retry_count }, {self ._tr ('failed_text','Failed')}: {self .failed_retry_count_in_session })" + ) + + if self .processed_retry_count >=self .total_files_for_retry : + if all (f .done ()for f in self .active_retry_futures ): + QTimer .singleShot (0 ,self ._retry_session_finished ) + + + def _retry_session_finished (self ): + self .log_signal .emit ("🏁 Retry session finished.") + self .log_signal .emit (f" Summary: {self .succeeded_retry_count } Succeeded, {self .failed_retry_count_in_session } Failed.") + + if self .retry_thread_pool : + self .retry_thread_pool .shutdown (wait =True ) + self .retry_thread_pool =None + + if self .external_link_download_thread and not self .external_link_download_thread .isRunning (): + self .external_link_download_thread .deleteLater () + self .external_link_download_thread =None + + self .active_retry_futures .clear () + self .active_retry_futures_map .clear () + self .files_for_current_retry_session .clear () + + if self .permanently_failed_files_for_dialog : + self .log_signal .emit (f"🆘 {self ._tr ('error_button_text','Error')} button enabled. {len (self .permanently_failed_files_for_dialog )} file(s) ultimately failed and can be viewed.") + + self .set_ui_enabled (not self ._is_download_active ()) + if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_button_text","❌ Cancel & Reset UI")) + self .progress_label .setText ( + f"{self ._tr ('retry_finished_text','Retry Finished')}. " + f"{self ._tr ('succeeded_text','Succeeded')}: {self .succeeded_retry_count }, " + f"{self ._tr ('failed_text','Failed')}: {self .failed_retry_count_in_session }. " + f"{self ._tr ('ready_for_new_task_text','Ready for new task.')}") + self .file_progress_label .setText ("") + if self .pause_event :self .pause_event .clear () + self .is_paused =False + + def toggle_active_log_view (self ): + if self .current_log_view =='progress': + self .current_log_view ='missed_character' + if self .log_view_stack :self .log_view_stack .setCurrentIndex (1 ) + if self .log_verbosity_toggle_button : + self .log_verbosity_toggle_button .setText (self .CLOSED_EYE_ICON ) + self .log_verbosity_toggle_button .setToolTip ("Current View: Missed Character Log. Click to switch to Progress Log.") + if self .progress_log_label :self .progress_log_label .setText (self ._tr ("missed_character_log_label_text","🚫 Missed Character Log:")) + else : + self .current_log_view ='progress' + if self .log_view_stack :self .log_view_stack .setCurrentIndex (0 ) + if self .log_verbosity_toggle_button : + self .log_verbosity_toggle_button .setText (self .EYE_ICON ) + self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.") + if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")) + + def reset_application_state(self): + # --- Stop all background tasks and threads --- + if self._is_download_active(): + # Try to cancel download thread + if self.download_thread and self.download_thread.isRunning(): + self.log_signal.emit("⚠️ Cancelling active download thread for reset...") + self.cancellation_event.set() + self.download_thread.requestInterruption() + self.download_thread.wait(3000) + if self.download_thread.isRunning(): + self.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.") + self.download_thread.deleteLater() + self.download_thread = None + + # Try to cancel thread pool + if self.thread_pool: + self.log_signal.emit(" Shutting down thread pool for reset...") + self.thread_pool.shutdown(wait=True, cancel_futures=True) + self.thread_pool = None + self.active_futures = [] + + # Try to cancel external link download thread + if self.external_link_download_thread and self.external_link_download_thread.isRunning(): + self.log_signal.emit(" Cancelling external link download thread for reset...") + self.external_link_download_thread.cancel() + self.external_link_download_thread.wait(3000) + self.external_link_download_thread.deleteLater() + self.external_link_download_thread = None + + # Try to cancel retry thread pool + if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool: + self.log_signal.emit(" Shutting down retry thread pool for reset...") + self.retry_thread_pool.shutdown(wait=True) + self.retry_thread_pool = None + if hasattr(self, 'active_retry_futures'): + self.active_retry_futures.clear() + if hasattr(self, 'active_retry_futures_map'): + self.active_retry_futures_map.clear() + + self.cancellation_event.clear() + if self.pause_event: + self.pause_event.clear() + self.is_paused = False + + # --- Reset UI and all state --- + self.log_signal.emit("🔄 Resetting application state to defaults...") + self._reset_ui_to_defaults() + self._load_saved_download_location() + self.main_log_output.clear() + self.external_log_output.clear() + if self.missed_character_log_output: + self.missed_character_log_output.clear() + + self.current_log_view = 'progress' + if self.log_view_stack: + self.log_view_stack.setCurrentIndex(0) + if self.progress_log_label: + self.progress_log_label.setText(self._tr("progress_log_label_text", "📜 Progress Log:")) + if self.log_verbosity_toggle_button: + self.log_verbosity_toggle_button.setText(self.EYE_ICON) + self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") + + # Clear all download-related state + self.external_link_queue.clear() + self.extracted_links_cache = [] + self._is_processing_external_link_queue = False + self._current_link_post_title = None + self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle")) + self.file_progress_label.setText("") + with self.downloaded_files_lock: + self.downloaded_files.clear() + with self.downloaded_file_hashes_lock: + self.downloaded_file_hashes.clear() + self.missed_title_key_terms_count.clear() + self.missed_title_key_terms_examples.clear() + self.logged_summary_for_key_term.clear() + self.already_logged_bold_key_terms.clear() + self.missed_key_terms_buffer.clear() + self.favorite_download_queue.clear() + self.only_links_log_display_mode = LOG_DISPLAY_LINKS + self.mega_download_log_preserved_once = False + self.permanently_failed_files_for_dialog.clear() + self._update_error_button_count() + self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION + self._update_favorite_scope_button_text() + self.retryable_failed_files_info.clear() + self.cancellation_message_logged_this_session = False + self.is_processing_favorites_queue = False + self.total_posts_to_process = 0 + self.processed_posts_count = 0 + self.download_counter = 0 + self.skip_counter = 0 + self.all_kept_original_filenames = [] + self.is_paused = False + self.is_fetcher_thread_running = False + self.interrupted_session_data = None + self.is_restore_pending = False + + self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style) + self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope) + self.settings.sync() + self._update_manga_filename_style_button_text() + self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False) + + self.set_ui_enabled(True) + self.log_signal.emit("✅ Application fully reset. Ready for new download.") + self.is_processing_favorites_queue = False + self.current_processing_favorite_item_info = None + self.favorite_download_queue.clear() + self.interrupted_session_data = None + self.is_restore_pending = False + self.last_link_input_text_for_queue_sync = "" + # Replace your current reset_application_state with the above. + + def _reset_ui_to_defaults(self): + """Resets all UI elements and relevant state to their default values.""" + # Clear all text fields + self.link_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() + if hasattr(self, 'remove_from_filename_input'): + self.remove_from_filename_input.clear() + self.character_search_input.clear() + self.thread_count_input.setText("4") + if hasattr(self, 'manga_date_prefix_input'): + self.manga_date_prefix_input.clear() + + # Set 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) + if self.favorite_mode_checkbox: + self.favorite_mode_checkbox.setChecked(False) + self.external_links_checkbox.setChecked(False) + if self.manga_mode_checkbox: + self.manga_mode_checkbox.setChecked(False) + if hasattr(self, 'use_cookie_checkbox'): + self.use_cookie_checkbox.setChecked(False) + self.selected_cookie_filepath = None + if hasattr(self, 'cookie_text_input'): + self.cookie_text_input.clear() + + # Reset log and progress displays + if self.main_log_output: + self.main_log_output.clear() + if self.external_log_output: + self.external_log_output.clear() + if self.missed_character_log_output: + self.missed_character_log_output.clear() + self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle")) + self.file_progress_label.setText("") + + # Reset internal state + self.missed_title_key_terms_count.clear() + self.missed_title_key_terms_examples.clear() + self.logged_summary_for_key_term.clear() + self.already_logged_bold_key_terms.clear() + self.missed_key_terms_buffer.clear() + self.permanently_failed_files_for_dialog.clear() + self.only_links_log_display_mode = LOG_DISPLAY_LINKS + self.cancellation_message_logged_this_session = False + self.mega_download_log_preserved_once = False + self.allow_multipart_download_setting = False + self.skip_words_scope = SKIP_SCOPE_POSTS + self.char_filter_scope = CHAR_SCOPE_TITLE + self.manga_filename_style = STYLE_POST_TITLE + self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION + self._update_skip_scope_button_text() + self._update_char_filter_scope_button_text() + self._update_manga_filename_style_button_text() + self._update_multipart_toggle_button_text() + self._update_favorite_scope_button_text() + self.current_log_view = 'progress' + self.is_paused = False + if self.pause_event: + self.pause_event.clear() + + # Reset extracted/external links state + self.external_link_queue.clear() + self.extracted_links_cache = [] + self._is_processing_external_link_queue = False + self._current_link_post_title = None + if self.download_extracted_links_button: + self.download_extracted_links_button.setEnabled(False) + + # Reset favorite/queue/session state + self.favorite_download_queue.clear() + self.is_processing_favorites_queue = False + self.current_processing_favorite_item_info = None + self.interrupted_session_data = None + self.is_restore_pending = False + self.last_link_input_text_for_queue_sync = "" + self._update_button_states_and_connections() + # Reset counters and progress + self.total_posts_to_process = 0 + self.processed_posts_count = 0 + self.download_counter = 0 + self.skip_counter = 0 + self.all_kept_original_filenames = [] + + # Reset log view and UI state + if self.log_view_stack: + self.log_view_stack.setCurrentIndex(0) + if self.progress_log_label: + self.progress_log_label.setText(self._tr("progress_log_label_text", "📜 Progress Log:")) + if self.log_verbosity_toggle_button: + self.log_verbosity_toggle_button.setText(self.EYE_ICON) + self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") + + # Reset character list filter + self.filter_character_list("") + + # Update UI for manga mode and multithreading + self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) + self.update_ui_for_manga_mode(False) + self.update_custom_folder_visibility(self.link_input.text()) + self.update_page_range_enabled_state() + self._update_cookie_input_visibility(False) + self._update_cookie_input_placeholders_and_tooltips() + + # Reset button states + self.download_btn.setEnabled(True) + self.cancel_btn.setEnabled(False) + if self.reset_button: + self.reset_button.setEnabled(True) + self.reset_button.setText(self._tr("reset_button_text", "🔄 Reset")) + self.reset_button.setToolTip(self._tr("reset_button_tooltip", "Reset all inputs and logs to default state (only when idle).")) + + # Reset favorite mode UI + if hasattr(self, 'favorite_mode_checkbox'): + self._handle_favorite_mode_toggle(False) + if hasattr(self, 'scan_content_images_checkbox'): + self.scan_content_images_checkbox.setChecked(False) + if hasattr(self, 'download_thumbnails_checkbox'): + self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked()) + + self.set_ui_enabled(True) + self.log_signal.emit("✅ UI reset to defaults. Ready for new operation.") + self._update_button_states_and_connections() + + def _show_feature_guide (self ): + steps_content_keys =[ + ("help_guide_step1_title","help_guide_step1_content"), + ("help_guide_step2_title","help_guide_step2_content"), + ("help_guide_step3_title","help_guide_step3_content"), + ("help_guide_step4_title","help_guide_step4_content"), + ("help_guide_step5_title","help_guide_step5_content"), + ("help_guide_step6_title","help_guide_step6_content"), + ("help_guide_step7_title","help_guide_step7_content"), + ("help_guide_step8_title","help_guide_step8_content"), + ("help_guide_step9_title","help_guide_step9_content"), + ("column_header_post_title","Post Title"), + ("column_header_date_uploaded","Date Uploaded"), + ] + + steps =[ + ] + for title_key ,content_key in steps_content_keys : + title =self ._tr (title_key ,title_key ) + content =self ._tr (content_key ,f"Content for {content_key } not found.") + steps .append ((title ,content )) + + guide_dialog =HelpGuideDialog (steps ,self ) + guide_dialog .exec_ () + + def prompt_add_character (self ,character_name ): + global KNOWN_NAMES + 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 : + if self .add_new_character (name_to_add =character_name , + is_group_to_add =False , + aliases_to_add =[character_name ], + suppress_similarity_prompt =False ): + self .log_signal .emit (f"✅ Added '{character_name }' to known names via background prompt.") + else :result =False ;self .log_signal .emit (f"ℹ️ Adding '{character_name }' via background prompt was declined, failed, or a similar name conflict was not overridden.") + self .character_prompt_response_signal .emit (result ) + + def receive_add_character_result (self ,result ): + with QMutexLocker (self .prompt_mutex ):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'}") + + def _update_multipart_toggle_button_text (self ): + if hasattr (self ,'multipart_toggle_button'): + if self .allow_multipart_download_setting : + self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON")) + self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON")) + else : + self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF")) + self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF")) + + def _update_error_button_count(self): + """Updates the Error button text to show the count of failed files.""" + if not hasattr(self, 'error_btn'): + return + + count = len(self.permanently_failed_files_for_dialog) + base_text = self._tr("error_button_text", "Error") + + if count > 0: + self.error_btn.setText(f"({count}) {base_text}") + else: + self.error_btn.setText(base_text) + + def _toggle_multipart_mode (self ): + if not self .allow_multipart_download_setting : + msg_box =QMessageBox (self ) + msg_box .setIcon (QMessageBox .Warning ) + msg_box .setWindowTitle ("Multi-part Download Advisory") + msg_box .setText ( + "Multi-part download advisory:

" + "
    " + "
  • Best suited for large files (e.g., single post videos).
  • " + "
  • When downloading a full creator feed with many small files (like images):" + "
    • May not offer significant speed benefits.
    • " + "
    • Could potentially make the UI feel choppy.
    • " + "
    • May spam the process log with rapid, numerous small download messages.
  • " + "
  • Consider using the 'Videos' filter if downloading a creator feed to primarily target large files for multi-part.
  • " + "

" + "Do you want to enable multi-part download?" + ) + proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole ) + cancel_button =msg_box .addButton ("Cancel",QMessageBox .RejectRole ) + msg_box .setDefaultButton (proceed_button ) + msg_box .exec_ () + + if msg_box .clickedButton ()==cancel_button : + self .log_signal .emit ("ℹ️ Multi-part download enabling cancelled by user.") + return + + self .allow_multipart_download_setting =not self .allow_multipart_download_setting + self ._update_multipart_toggle_button_text () + self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting ) + self .log_signal .emit (f"ℹ️ Multi-part download set to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}") + + def _open_known_txt_file (self ): + if not os .path .exists (self .config_file ): + QMessageBox .warning (self ,"File Not Found", + f"The file 'Known.txt' was not found at:\n{self .config_file }\n\n" + "It will be created automatically when you add a known name or close the application.") + self .log_signal .emit (f"ℹ️ 'Known.txt' not found at {self .config_file }. It will be created later.") + return + + try : + if sys .platform =="win32": + os .startfile (self .config_file ) + elif sys .platform =="darwin": + subprocess .call (['open',self .config_file ]) + else : + subprocess .call (['xdg-open',self .config_file ]) + self .log_signal .emit (f"ℹ️ Attempted to open '{os .path .basename (self .config_file )}' with the default editor.") + except FileNotFoundError : + QMessageBox .critical (self ,"Error",f"Could not find '{os .path .basename (self .config_file )}' at {self .config_file } to open it.") + self .log_signal .emit (f"❌ Error: '{os .path .basename (self .config_file )}' not found at {self .config_file } when trying to open.") + except Exception as e : + QMessageBox .critical (self ,"Error Opening File",f"Could not open '{os .path .basename (self .config_file )}':\n{e }") + self .log_signal .emit (f"❌ Error opening '{os .path .basename (self .config_file )}': {e }") + + def _show_add_to_filter_dialog (self ): + global KNOWN_NAMES + if not KNOWN_NAMES : + QMessageBox .information (self ,"No Known Names","Your 'Known.txt' list is empty. Add some names first.") + return + + dialog =KnownNamesFilterDialog (KNOWN_NAMES ,self ,self ) + if dialog .exec_ ()==QDialog .Accepted : + selected_entries =dialog .get_selected_entries () + if selected_entries : + self ._add_names_to_character_filter_input (selected_entries ) + + def _add_names_to_character_filter_input (self ,selected_entries ): + """ + Adds the selected known name entries to the character filter input field. + """ + if not selected_entries : + return + + names_to_add_str_list =[] + for entry in selected_entries : + if entry .get ("is_group"): + aliases_str =", ".join (entry .get ("aliases",[])) + names_to_add_str_list .append (f"({aliases_str })~") + else : + names_to_add_str_list .append (entry .get ("name","")) + + names_to_add_str_list =[s for s in names_to_add_str_list if s ] + + if not names_to_add_str_list : + return + + current_filter_text =self .character_input .text ().strip () + new_text_to_append =", ".join (names_to_add_str_list ) + + self .character_input .setText (f"{current_filter_text }, {new_text_to_append }"if current_filter_text else new_text_to_append ) + self .log_signal .emit (f"ℹ️ Added to character filter: {new_text_to_append }") + + def _update_favorite_scope_button_text (self ): + if not hasattr (self ,'favorite_scope_toggle_button')or not self .favorite_scope_toggle_button : + return + if self .favorite_download_scope ==FAVORITE_SCOPE_SELECTED_LOCATION : + self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_selected_location_text","Scope: Selected Location")) + + elif self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS : + self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_artist_folders_text","Scope: Artist Folders")) + + else : + self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_unknown_text","Scope: Unknown")) + + + def _cycle_favorite_scope (self ): + if self .favorite_download_scope ==FAVORITE_SCOPE_SELECTED_LOCATION : + self .favorite_download_scope =FAVORITE_SCOPE_ARTIST_FOLDERS + else : + self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION + self ._update_favorite_scope_button_text () + self .log_signal .emit (f"ℹ️ Favorite download scope changed to: '{self .favorite_download_scope }'") + + def _show_empty_popup (self ): + """Creates and shows the empty popup dialog.""" + if self.is_restore_pending: + QMessageBox.information(self, self._tr("restore_pending_title", "Restore Pending"), + self._tr("restore_pending_message_creator_selection", + "Please 'Restore Download' or 'Discard Session' before selecting new creators.")) + return + + dialog =EmptyPopupDialog (self .app_base_dir ,self ,self ) + if dialog .exec_ ()==QDialog .Accepted : + if hasattr (dialog ,'selected_creators_for_queue')and dialog .selected_creators_for_queue : + self .favorite_download_queue .clear () + + for creator_data in dialog .selected_creators_for_queue : + service =creator_data .get ('service') + creator_id =creator_data .get ('id') + creator_name =creator_data .get ('name','Unknown Creator') + domain =dialog ._get_domain_for_service (service ) + + if service and creator_id : + url =f"https://{domain }/{service }/user/{creator_id }" + queue_item ={ + 'url':url , + 'name':creator_name , + 'name_for_folder':creator_name , + 'type':'creator_popup_selection', + 'scope_from_popup':dialog .current_scope_mode + } + self .favorite_download_queue .append (queue_item ) + + if self .favorite_download_queue : + self .log_signal .emit (f"ℹ️ {len (self .favorite_download_queue )} creators added to download queue from popup. Click 'Start Download' to process.") + if hasattr (self ,'link_input'): + self .last_link_input_text_for_queue_sync =self .link_input .text () + + def _show_favorite_artists_dialog (self ): + if self ._is_download_active ()or self .is_processing_favorites_queue : + QMessageBox .warning (self ,"Busy","Another download operation is already in progress.") + return + + cookies_config ={ + 'use_cookie':self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False , + 'cookie_text':self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "", + 'selected_cookie_file':self .selected_cookie_filepath , + 'app_base_dir':self .app_base_dir + } + + dialog =FavoriteArtistsDialog (self ,cookies_config ) + if dialog .exec_ ()==QDialog .Accepted : + selected_artists =dialog .get_selected_artists () + if selected_artists : + if len (selected_artists )>1 and self .link_input : + display_names =", ".join ([artist ['name']for artist in selected_artists ]) + if self .link_input : + self .link_input .clear () + self .link_input .setPlaceholderText (f"{len (selected_artists )} favorite artists selected for download queue.") + self .log_signal .emit (f"ℹ️ Multiple favorite artists selected. Displaying names: {display_names }") + elif len (selected_artists )==1 : + self .link_input .setText (selected_artists [0 ]['url']) + self .log_signal .emit (f"ℹ️ Single favorite artist selected: {selected_artists [0 ]['name']}") + + self .log_signal .emit (f"ℹ️ Queuing {len (selected_artists )} favorite artist(s) for download.") + for artist_data in selected_artists : + self .favorite_download_queue .append ({'url':artist_data ['url'],'name':artist_data ['name'],'name_for_folder':artist_data ['name'],'type':'artist'}) + + if not self .is_processing_favorites_queue : + self ._process_next_favorite_download () + else : + self .log_signal .emit ("ℹ️ No favorite artists were selected for download.") + QMessageBox .information (self , + self ._tr ("fav_artists_no_selection_title","No Selection"), + self ._tr ("fav_artists_no_selection_message","Please select at least one artist to download.")) + else : + self .log_signal .emit ("ℹ️ Favorite artists selection cancelled.") + + def _show_favorite_posts_dialog (self ): + if self ._is_download_active ()or self .is_processing_favorites_queue : + QMessageBox .warning (self ,"Busy","Another download operation is already in progress.") + return + + cookies_config ={ + 'use_cookie':self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False , + 'cookie_text':self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "", + 'selected_cookie_file':self .selected_cookie_filepath , + 'app_base_dir':self .app_base_dir + } + global KNOWN_NAMES + + target_domain_preference_for_fetch =None + + if cookies_config ['use_cookie']: + self .log_signal .emit ("Favorite Posts: 'Use Cookie' is checked. Determining target domain...") + kemono_cookies =prepare_cookies_for_request ( + cookies_config ['use_cookie'], + cookies_config ['cookie_text'], + cookies_config ['selected_cookie_file'], + cookies_config ['app_base_dir'], + lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Kemono] {msg }"), + target_domain ="kemono.su" + ) + coomer_cookies =prepare_cookies_for_request ( + cookies_config ['use_cookie'], + cookies_config ['cookie_text'], + cookies_config ['selected_cookie_file'], + cookies_config ['app_base_dir'], + lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Coomer] {msg }"), + target_domain ="coomer.su" + ) + + kemono_ok =bool (kemono_cookies ) + coomer_ok =bool (coomer_cookies ) + + if kemono_ok and not coomer_ok : + target_domain_preference_for_fetch ="kemono.su" + self .log_signal .emit (" ↳ Only Kemono.su cookies loaded. Will fetch favorites from Kemono.su only.") + elif coomer_ok and not kemono_ok : + target_domain_preference_for_fetch ="coomer.su" + self .log_signal .emit (" ↳ Only Coomer.su cookies loaded. Will fetch favorites from Coomer.su only.") + elif kemono_ok and coomer_ok : + target_domain_preference_for_fetch =None + self .log_signal .emit (" ↳ Cookies for both Kemono.su and Coomer.su loaded. Will attempt to fetch from both.") + else : + self .log_signal .emit (" ↳ No valid cookies loaded for Kemono.su or Coomer.su.") + cookie_help_dialog =CookieHelpDialog (self ,self ) + cookie_help_dialog .exec_ () + return + else : + self .log_signal .emit ("Favorite Posts: 'Use Cookie' is NOT checked. Cookies are required.") + cookie_help_dialog =CookieHelpDialog (self ,self ) + cookie_help_dialog .exec_ () + return + + dialog =FavoritePostsDialog (self ,cookies_config ,KNOWN_NAMES ,target_domain_preference_for_fetch ) + if dialog .exec_ ()==QDialog .Accepted : + selected_posts =dialog .get_selected_posts () + if selected_posts : + self .log_signal .emit (f"ℹ️ Queuing {len (selected_posts )} favorite post(s) for download.") + for post_data in selected_posts : + domain =self ._get_domain_for_service (post_data ['service']) + direct_post_url =f"https://{domain }/{post_data ['service']}/user/{str (post_data ['creator_id'])}/post/{str (post_data ['post_id'])}" + + queue_item ={ + 'url':direct_post_url , + 'name':post_data ['title'], + 'name_for_folder':post_data ['creator_name_resolved'], + 'type':'post' + } + self .favorite_download_queue .append (queue_item ) + + if not self .is_processing_favorites_queue : + self ._process_next_favorite_download () + else : + self .log_signal .emit ("ℹ️ No favorite posts were selected for download.") + else : + self .log_signal .emit ("ℹ️ Favorite posts selection cancelled.") + + def _process_next_favorite_download (self ): + if self ._is_download_active (): + self .log_signal .emit ("ℹ️ Waiting for current download to finish before starting next favorite.") + return + if not self .favorite_download_queue : + if self .is_processing_favorites_queue : + self .is_processing_favorites_queue =False + item_type_log ="item" + if hasattr (self ,'current_processing_favorite_item_info')and self .current_processing_favorite_item_info : + item_type_log =self .current_processing_favorite_item_info .get ('type','item') + self .log_signal .emit (f"✅ All {item_type_log } downloads from favorite queue have been processed.") + self .set_ui_enabled (True ) + return + if not self .is_processing_favorites_queue : + self .is_processing_favorites_queue =True + self .current_processing_favorite_item_info =self .favorite_download_queue .popleft () + next_url =self .current_processing_favorite_item_info ['url'] + item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item') + + item_type =self .current_processing_favorite_item_info .get ('type','artist') + self .log_signal .emit (f"▶️ Processing next favorite from queue: '{item_display_name }' ({next_url })") + + override_dir =None + item_scope =self .current_processing_favorite_item_info .get ('scope_from_popup') + if item_scope is None : + item_scope =self .favorite_download_scope + + main_download_dir =self .dir_input .text ().strip () + + should_create_artist_folder =False + if item_type =='creator_popup_selection'and item_scope ==EmptyPopupDialog .SCOPE_CREATORS : + should_create_artist_folder =True + elif item_type !='creator_popup_selection'and self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS : + should_create_artist_folder =True + + if should_create_artist_folder and main_download_dir : + folder_name_key =self .current_processing_favorite_item_info .get ('name_for_folder','Unknown_Folder') + item_specific_folder_name =clean_folder_name (folder_name_key ) + override_dir =os .path .normpath (os .path .join (main_download_dir ,item_specific_folder_name )) + self .log_signal .emit (f" Scope requires artist folder. Target directory: '{override_dir }'") + + success_starting_download =self .start_download (direct_api_url =next_url ,override_output_dir =override_dir ) + + if not success_starting_download : + self .log_signal .emit (f"⚠️ Failed to initiate download for '{item_display_name }'. Skipping this item in queue.") + self .download_finished (total_downloaded =0 ,total_skipped =1 ,cancelled_by_user =True ,kept_original_names_list =[]) + +class ExternalLinkDownloadThread (QThread ): + """A QThread to handle downloading multiple external links sequentially.""" + progress_signal =pyqtSignal (str ) + file_complete_signal =pyqtSignal (str ,bool ) + finished_signal =pyqtSignal () + + def __init__ (self ,tasks_to_download ,download_base_path ,parent_logger_func ,parent =None ): + super ().__init__ (parent ) + self .tasks =tasks_to_download + self .download_base_path =download_base_path + self .parent_logger_func =parent_logger_func + self .is_cancelled =False + + def run (self ): + self .progress_signal .emit (f"ℹ️ Starting external link download thread for {len (self .tasks )} link(s).") + for i ,task_info in enumerate (self .tasks ): + if self .is_cancelled : + self .progress_signal .emit ("External link download cancelled by user.") + break + + platform =task_info .get ('platform','unknown').lower () + full_mega_url =task_info ['url'] + post_title =task_info ['title'] + key =task_info .get ('key','') + + self .progress_signal .emit (f"Download ({i +1 }/{len (self .tasks )}): Starting '{post_title }' ({platform .upper ()}) from {full_mega_url }") + + try : + if platform =='mega': + + if key : + parsed_original_url =urlparse (full_mega_url ) + if key not in parsed_original_url .fragment : + base_url_no_fragment =full_mega_url .split ('#')[0 ] + full_mega_url_with_key =f"{base_url_no_fragment }#{key }" + self .progress_signal .emit (f" Adjusted Mega URL with key: {full_mega_url_with_key }") + else : + full_mega_url_with_key =full_mega_url + else : + full_mega_url_with_key =full_mega_url + drive_download_mega_file (full_mega_url_with_key ,self .download_base_path ,logger_func =self .parent_logger_func ) + elif platform =='google drive': + download_gdrive_file (full_mega_url ,self .download_base_path ,logger_func =self .parent_logger_func ) + elif platform =='dropbox': + download_dropbox_file (full_mega_url ,self .download_base_path ,logger_func =self .parent_logger_func ) + else : + self .progress_signal .emit (f"⚠️ Unsupported platform '{platform }' for link: {full_mega_url }") + self .file_complete_signal .emit (full_mega_url ,False ) + continue + self .file_complete_signal .emit (full_mega_url ,True ) + except Exception as e : + self .progress_signal .emit (f"❌ Error downloading ({platform .upper ()}) link '{full_mega_url }' (from post '{post_title }'): {e }") + self .file_complete_signal .emit (full_mega_url ,False ) + self .finished_signal .emit () + + def cancel (self ): + self .is_cancelled =True \ No newline at end of file diff --git a/src/core/api_client.py b/src/core/api_client.py index 9a37b5f..03dea4d 100644 --- a/src/core/api_client.py +++ b/src/core/api_client.py @@ -1,12 +1,10 @@ -# --- Standard Library Imports --- import time import traceback from urllib.parse import urlparse - -# --- Third-Party Library Imports --- +import json # Ensure json is imported import requests -# --- Local Application Imports --- +# (Keep the rest of your imports) from ..utils.network_utils import extract_post_info, prepare_cookies_for_request from ..config.constants import ( STYLE_DATE_POST_TITLE @@ -15,36 +13,24 @@ from ..config.constants import ( def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None): """ - Fetches a single page of posts from the API with retry logic. - - Args: - api_url_base (str): The base URL for the user's posts. - headers (dict): The request headers. - offset (int): The offset for pagination. - logger (callable): Function to log messages. - cancellation_event (threading.Event): Event to signal cancellation. - pause_event (threading.Event): Event to signal pause. - cookies_dict (dict): A dictionary of cookies to include in the request. - - Returns: - list: A list of post data dictionaries from the API. - - Raises: - RuntimeError: If the fetch fails after all retries or encounters a non-retryable error. + Fetches a single page of posts from the API with robust retry logic. + NEW: Requests only essential fields to keep the response size small and reliable. """ if cancellation_event and cancellation_event.is_set(): - logger(" Fetch cancelled before request.") raise RuntimeError("Fetch operation cancelled by user.") if pause_event and pause_event.is_set(): logger(" Post fetching paused...") while pause_event.is_set(): if cancellation_event and cancellation_event.is_set(): - logger(" Post fetching cancelled while paused.") - raise RuntimeError("Fetch operation cancelled by user.") + raise RuntimeError("Fetch operation cancelled by user while paused.") time.sleep(0.5) logger(" Post fetching resumed.") - - paginated_url = f'{api_url_base}?o={offset}' + + # --- MODIFICATION: Added `fields` to the URL to request only metadata --- + # This prevents the large 'content' field from being included in the list, avoiding timeouts. + fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags" + paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}' + max_retries = 3 retry_delay = 5 @@ -52,22 +38,18 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev if cancellation_event and cancellation_event.is_set(): raise RuntimeError("Fetch operation cancelled by user during retry loop.") - log_message = f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})" + log_message = f" Fetching post list: {api_url_base}?o={offset} (Page approx. {offset // 50 + 1})" if attempt > 0: log_message += f" (Attempt {attempt + 1}/{max_retries})" logger(log_message) try: - response = requests.get(paginated_url, headers=headers, timeout=(15, 90), cookies=cookies_dict) + # We can now remove the streaming logic as the response will be small and fast. + response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) response.raise_for_status() - - if 'application/json' not in response.headers.get('Content-Type', '').lower(): - logger(f"⚠️ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}") - return [] - return response.json() - except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + except requests.exceptions.RequestException as e: logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}") if attempt < max_retries - 1: delay = retry_delay * (2 ** attempt) @@ -76,18 +58,46 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev continue else: logger(f" ❌ Failed to fetch page after {max_retries} attempts.") - raise RuntimeError(f"Timeout or connection error fetching offset {offset}") - except requests.exceptions.RequestException as e: - err_msg = f"Error fetching offset {offset}: {e}" - if e.response is not None: - err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})" - raise RuntimeError(err_msg) - except ValueError as e: # JSON decode error - raise RuntimeError(f"Error decoding JSON from offset {offset}: {e}. Response: {response.text[:200]}") + raise RuntimeError(f"Network error fetching offset {offset}") + except json.JSONDecodeError as e: + logger(f" ❌ Failed to decode JSON on page fetch (Attempt {attempt + 1}): {e}") + if attempt < max_retries - 1: + delay = retry_delay * (2 ** attempt) + logger(f" Retrying in {delay} seconds...") + time.sleep(delay) + continue + else: + raise RuntimeError(f"JSONDecodeError fetching offset {offset}") raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.") +def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None): + """ + --- NEW FUNCTION --- + Fetches the full data, including the 'content' field, for a single post. + """ + post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}" + logger(f" Fetching full content for post ID {post_id}...") + try: + # Use streaming here as a precaution for single posts that are still very large. + with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response: + response.raise_for_status() + response_body = b"" + for chunk in response.iter_content(chunk_size=8192): + response_body += chunk + + full_post_data = json.loads(response_body) + # The API sometimes wraps the post in a list, handle that. + if isinstance(full_post_data, list) and full_post_data: + return full_post_data[0] + return full_post_data + + except Exception as e: + logger(f" ❌ Failed to fetch full content for post {post_id}: {e}") + return None + + def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None): """Fetches all comments for a specific post.""" if cancellation_event and cancellation_event.is_set(): diff --git a/src/core/workers.py b/src/core/workers.py index 0f2ba1e..d852296 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -20,6 +20,26 @@ try: from PIL import Image except ImportError: Image = None +# +try: + from fpdf import FPDF + # Add a simple class to handle the header/footer for stories + class PDF(FPDF): + def header(self): + pass # No header + def footer(self): + self.set_y(-15) + self.set_font('Arial', 'I', 8) + self.cell(0, 10, 'Page %s' % self.page_no(), 0, 0, 'C') + +except ImportError: + FPDF = None + +try: + from docx import Document +except ImportError: + Document = None + # --- PyQt5 Imports --- from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess # --- Local Application Imports --- @@ -48,7 +68,8 @@ class PostProcessorSignals (QObject ): file_progress_signal =pyqtSignal (str ,object ) file_successfully_downloaded_signal =pyqtSignal (dict ) missed_character_post_signal =pyqtSignal (str ,str ) - + worker_finished_signal = pyqtSignal(tuple) + class PostProcessorWorker: def __init__ (self ,post_data ,download_root ,known_names , filter_character_list ,emitter , @@ -81,6 +102,10 @@ class PostProcessorWorker: keep_in_post_duplicates=False, session_file_path=None, session_lock=None, + text_only_scope=None, + text_export_format='txt', + single_pdf_mode=False, + project_root_dir=None, ): self .post =post_data self .download_root =download_root @@ -134,6 +159,10 @@ class PostProcessorWorker: self.keep_in_post_duplicates = keep_in_post_duplicates self.session_file_path = session_file_path self.session_lock = session_lock + self.text_only_scope = text_only_scope + self.text_export_format = text_export_format + self.single_pdf_mode = single_pdf_mode # <-- ADD THIS LINE + self.project_root_dir = project_root_dir if self .compress_images and Image is None : self .logger ("⚠️ Image compression disabled: Pillow library not found.") @@ -557,6 +586,8 @@ class PostProcessorWorker: final_total_for_progress =total_size_bytes if download_successful_flag and total_size_bytes >0 else downloaded_size_bytes self ._emit_signal ('file_progress',api_original_filename ,(downloaded_size_bytes ,final_total_for_progress )) +# --- Start of Replacement Block --- + # Rescue download if an IncompleteRead error occurred but the file is complete if (not download_successful_flag and isinstance(last_exception_for_retry_later, http.client.IncompleteRead) and @@ -614,33 +645,32 @@ class PostProcessorWorker: is_img_for_compress_check = is_image(api_original_filename) if is_img_for_compress_check and self.compress_images and Image and downloaded_size_bytes > (1.5 * 1024 * 1024): - # ... (This block for image compression remains the same) - self .logger (f" Compressing '{api_original_filename }' ({downloaded_size_bytes /(1024 *1024 ):.2f} MB)...") - if self ._check_pause (f"Image compression for '{api_original_filename }'"):return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - img_content_for_pillow =None - try : - with open (downloaded_part_file_path ,'rb')as f_img_in : - img_content_for_pillow =BytesIO (f_img_in .read ()) - with Image .open (img_content_for_pillow )as img_obj : - if img_obj .mode =='P':img_obj =img_obj .convert ('RGBA') - elif img_obj .mode not in ['RGB','RGBA','L']:img_obj =img_obj .convert ('RGB') - compressed_output_io =BytesIO () - img_obj .save (compressed_output_io ,format ='WebP',quality =80 ,method =4 ) - compressed_size =compressed_output_io .getbuffer ().nbytes - if compressed_size >Save Fail for '{final_filename_on_disk}': {save_err}") - if os.path.exists(final_save_path): - try: os.remove(final_save_path) - except OSError: self.logger(f" -> Failed to remove partially saved file: {final_save_path}") - return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None + self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}") + if os.path.exists(final_save_path): + try: os.remove(final_save_path) + except OSError: self.logger(f" -> Failed to remove partially saved file: {final_save_path}") + + # --- FIX: Report as a permanent failure so it appears in the error dialog --- + permanent_failure_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, } + return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details finally: if data_to_write_io and hasattr(data_to_write_io, 'close'): data_to_write_io.close() @@ -738,14 +771,16 @@ class PostProcessorWorker: effective_save_folder =target_folder_path filename_after_styling_and_word_removal =filename_to_save_in_main_path - try : - os .makedirs (effective_save_folder ,exist_ok =True ) - except OSError as e : - self .logger (f" ❌ Critical error creating directory '{effective_save_folder }': {e }. Skipping file '{api_original_filename }'.") - if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ): - try :os .remove (downloaded_part_file_path ) - except OSError :pass - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None + try: + os.makedirs(effective_save_folder, exist_ok=True) + except OSError as e: + self.logger(f" ❌ Critical error creating directory '{effective_save_folder}': {e}. Skipping file '{api_original_filename}'.") + if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): + try: os.remove(downloaded_part_file_path) + except OSError: pass + # --- FIX: Report as a permanent failure so it appears in the error dialog --- + permanent_failure_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, } + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details data_to_write_io =None filename_after_compression =filename_after_styling_and_word_removal @@ -849,8 +884,8 @@ class PostProcessorWorker: data_to_write_io .close () def process (self ): - if self ._check_pause (f"Post processing for ID {self .post .get ('id','N/A')}"):return 0 ,0 ,[],[],[],None - if self .check_cancel ():return 0 ,0 ,[],[],[],None + if self ._check_pause (f"Post processing for ID {self .post .get ('id','N/A')}"):return 0 ,0 ,[],[],[],None, None + if self .check_cancel ():return 0 ,0 ,[],[],[],None, None current_character_filters =self ._get_current_character_filters () kept_original_filenames_for_log =[] retryable_failures_this_post =[] @@ -986,23 +1021,23 @@ class PostProcessorWorker: if self .char_filter_scope ==CHAR_SCOPE_TITLE and not post_is_candidate_by_title_char_match : self .logger (f" -> Skip Post (Scope: Title - No Char Match): Title '{post_title [:50 ]}' does not match character filters.") self ._emit_signal ('missed_character_post',post_title ,"No title match for character filter") - return 0 ,num_potential_files_in_post ,[],[],[],None + return 0 ,num_potential_files_in_post ,[],[],[],None, None if self .char_filter_scope ==CHAR_SCOPE_COMMENTS and not post_is_candidate_by_file_char_match_in_comment_scope and not post_is_candidate_by_comment_char_match : self .logger (f" -> Skip Post (Scope: Comments - No Char Match in Comments): Post ID '{post_id }', Title '{post_title [:50 ]}...'") if self .emitter and hasattr (self .emitter ,'missed_character_post_signal'): self ._emit_signal ('missed_character_post',post_title ,"No character match in files or comments (Comments scope)") - return 0 ,num_potential_files_in_post ,[],[],[],None + return 0 ,num_potential_files_in_post ,[],[],[],None, None if self .skip_words_list and (self .skip_words_scope ==SKIP_SCOPE_POSTS or self .skip_words_scope ==SKIP_SCOPE_BOTH ): if self ._check_pause (f"Skip words (post title) for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None post_title_lower =post_title .lower () for skip_word in self .skip_words_list : if skip_word .lower ()in post_title_lower : self .logger (f" -> Skip Post (Keyword in Title '{skip_word }'): '{post_title [:50 ]}...'. Scope: {self .skip_words_scope }") - return 0 ,num_potential_files_in_post ,[],[],[],None + return 0 ,num_potential_files_in_post ,[],[],[],None, None if not self .extract_links_only and self .manga_mode_active and current_character_filters and (self .char_filter_scope ==CHAR_SCOPE_TITLE or self .char_filter_scope ==CHAR_SCOPE_BOTH )and not post_is_candidate_by_title_char_match : self .logger (f" -> Skip Post (Manga Mode with Title/Both Scope - No Title Char Match): Title '{post_title [:50 ]}' doesn't match filters.") self ._emit_signal ('missed_character_post',post_title ,"Manga Mode: No title match for character filter (Title/Both scope)") - return 0 ,num_potential_files_in_post ,[],[],[],None + return 0 ,num_potential_files_in_post ,[],[],[],None, None if not isinstance (post_attachments ,list ): self .logger (f"⚠️ Corrupt attachment data for post {post_id } (expected list, got {type (post_attachments )}). Skipping attachments.") post_attachments =[] @@ -1171,6 +1206,156 @@ class PostProcessorWorker: break determined_post_save_path_for_history =os .path .join (base_path_for_post_subfolder ,final_post_subfolder_name ) + if self.filter_mode == 'text_only' and not self.extract_links_only: + self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})") + + # --- Apply Title-based filters to ensure post is a candidate --- + post_title_lower = post_title.lower() + if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH): + for skip_word in self.skip_words_list: + if skip_word.lower() in post_title_lower: + self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'.") + return 0, num_potential_files_in_post, [], [], [], None, None + + if current_character_filters and not post_is_candidate_by_title_char_match and not post_is_candidate_by_comment_char_match and not post_is_candidate_by_file_char_match_in_comment_scope: + self.logger(f" -> Skip Post (No character match for text extraction): '{post_title[:50]}...'.") + return 0, num_potential_files_in_post, [], [], [], None, None + + # --- Get the text content based on scope --- + raw_text_content = "" + final_post_data = post_data + + # Fetch full post data if content is missing and scope is 'content' + if self.text_only_scope == 'content' and 'content' not in final_post_data: + self.logger(f" Post {post_id} is missing 'content' field, fetching full data...") + parsed_url = urlparse(self.api_url_input) + api_domain = parsed_url.netloc + cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain) + + from .api_client import fetch_single_post_data # Local import to avoid circular dependency issues + full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies) + if full_data: + final_post_data = full_data + + if self.text_only_scope == 'content': + raw_text_content = final_post_data.get('content', '') + elif self.text_only_scope == 'comments': + try: + parsed_url = urlparse(self.api_url_input) + api_domain = parsed_url.netloc + comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event) + if comments_data: + comment_texts = [] + for comment in comments_data: + user = comment.get('user', {}).get('name', 'Unknown User') + timestamp = comment.get('updated', 'No Date') + body = strip_html_tags(comment.get('content', '')) + comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n") + raw_text_content = "\n".join(comment_texts) + except Exception as e: + self.logger(f" ❌ Error fetching comments for text-only mode: {e}") + + if not raw_text_content or not raw_text_content.strip(): + self.logger(" -> Skip Saving Text: No content/comments found or fetched.") + return 0, num_potential_files_in_post, [], [], [], None, None + + # --- Robust HTML-to-TEXT Conversion --- + paragraph_pattern = re.compile(r'(.*?)

', re.IGNORECASE | re.DOTALL) + html_paragraphs = paragraph_pattern.findall(raw_text_content) + cleaned_text = "" + if not html_paragraphs: + self.logger(" ⚠️ No

tags found. Falling back to basic HTML cleaning for the whole block.") + text_with_br = re.sub(r'', '\n', raw_text_content, flags=re.IGNORECASE) + cleaned_text = re.sub(r'<.*?>', '', text_with_br) + else: + cleaned_paragraphs_list = [] + for p_content in html_paragraphs: + p_with_br = re.sub(r'', '\n', p_content, flags=re.IGNORECASE) + p_cleaned = re.sub(r'<.*?>', '', p_with_br) + p_final = html.unescape(p_cleaned).strip() + if p_final: + cleaned_paragraphs_list.append(p_final) + cleaned_text = '\n\n'.join(cleaned_paragraphs_list) + cleaned_text = cleaned_text.replace('…', '...') + + # --- Logic for Single PDF Mode (File-based) --- + if self.single_pdf_mode: + if not cleaned_text: + return 0, 0, [], [], [], None, None + + content_data = { + 'title': post_title, + 'content': cleaned_text, + 'published': self.post.get('published') or self.post.get('added') + } + temp_dir = os.path.join(self.app_base_dir, "appdata") + os.makedirs(temp_dir, exist_ok=True) + temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json" + temp_filepath = os.path.join(temp_dir, temp_filename) + + try: + with open(temp_filepath, 'w', encoding='utf-8') as f: + json.dump(content_data, f, indent=2) + self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.") + return 0, 0, [], [], [], None, temp_filepath + except Exception as e: + self.logger(f" ❌ Failed to write temporary file for single PDF: {e}") + return 0, 0, [], [], [], None, None + + # --- Logic for Individual File Saving --- + else: + file_extension = self.text_export_format + txt_filename = clean_filename(post_title) + f".{file_extension}" + final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename) + + try: + os.makedirs(determined_post_save_path_for_history, exist_ok=True) + base, ext = os.path.splitext(final_save_path) + counter = 1 + while os.path.exists(final_save_path): + final_save_path = f"{base}_{counter}{ext}" + counter += 1 + + if file_extension == 'pdf': + if FPDF: + self.logger(f" Converting to PDF...") + pdf = PDF() + font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + try: + if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") + pdf.add_font('DejaVu', '', font_path, uni=True) + pdf.set_font('DejaVu', '', 12) + except Exception as font_error: + self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") + pdf.set_font('Arial', '', 12) + pdf.add_page() + pdf.multi_cell(0, 5, cleaned_text) + pdf.output(final_save_path) + else: + self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") + final_save_path = os.path.splitext(final_save_path)[0] + ".txt" + with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) + + elif file_extension == 'docx': + if Document: + self.logger(f" Converting to DOCX...") + document = Document() + document.add_paragraph(cleaned_text) + document.save(final_save_path) + else: + self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.") + final_save_path = os.path.splitext(final_save_path)[0] + ".txt" + with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) + + else: # Default to TXT + with open(final_save_path, 'w', encoding='utf-8') as f: + f.write(cleaned_text) + + self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'") + return 1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None + except Exception as e: + self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}") + return 0, num_potential_files_in_post, [], [], [], None, None if not self .extract_links_only and self .use_subfolders and self .skip_words_list : if self ._check_pause (f"Folder keyword skip check for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None @@ -1179,7 +1364,7 @@ class PostProcessorWorker: if any (skip_word .lower ()in folder_name_to_check .lower ()for skip_word in self .skip_words_list ): matched_skip =next ((sw for sw in self .skip_words_list if sw .lower ()in folder_name_to_check .lower ()),"unknown_skip_word") self .logger (f" -> Skip Post (Folder Keyword): Potential folder '{folder_name_to_check }' contains '{matched_skip }'.") - return 0 ,num_potential_files_in_post ,[],[],[],None + return 0 ,num_potential_files_in_post ,[],[],[],None, None if (self .show_external_links or self .extract_links_only )and post_content_html : if self ._check_pause (f"External link extraction for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None try : @@ -1555,7 +1740,17 @@ class PostProcessorWorker: except OSError as e_rmdir : self .logger (f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness }': {e_rmdir }") - return total_downloaded_this_post ,total_skipped_this_post ,kept_original_filenames_for_log ,retryable_failures_this_post ,permanent_failures_this_post ,history_data_for_this_post + result_tuple = (total_downloaded_this_post, total_skipped_this_post, + kept_original_filenames_for_log, retryable_failures_this_post, + permanent_failures_this_post, history_data_for_this_post, + None) # The 7th item is None because we already saved the temp file + + # In Single PDF mode, the 7th item is the temp file path we created. + if self.single_pdf_mode and os.path.exists(temp_filepath): + result_tuple = (0, 0, [], [], [], None, temp_filepath) + + self._emit_signal('worker_finished', result_tuple) + return # The method now returns nothing. class DownloadThread (QThread ): progress_signal =pyqtSignal (str ) @@ -1605,6 +1800,10 @@ class DownloadThread (QThread ): cookie_text ="", session_file_path=None, session_lock=None, + text_only_scope=None, + text_export_format='txt', + single_pdf_mode=False, + project_root_dir=None, ): super ().__init__ () self .api_url_input =api_url_input @@ -1660,6 +1859,11 @@ class DownloadThread (QThread ): self.session_file_path = session_file_path self.session_lock = session_lock self.history_candidates_buffer =deque (maxlen =8 ) + self.text_only_scope = text_only_scope + self.text_export_format = text_export_format + self.single_pdf_mode = single_pdf_mode # <-- ADD THIS LINE + self.project_root_dir = project_root_dir # Add this assignment + if self .compress_images and Image is None : self .logger ("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") self .compress_images =False @@ -1682,162 +1886,172 @@ class DownloadThread (QThread ): self .logger ("⏭️ Skip requested for current file (single-thread mode).") self .skip_current_file_flag .set () else :self .logger ("ℹ️ Skip file: No download active or skip flag not available for current context.") + def run (self ): + """ + The main execution method for the single-threaded download process. + This version is corrected to handle 7 return values from the worker and + to pass the 'single_pdf_mode' setting correctly. + """ grand_total_downloaded_files =0 grand_total_skipped_files =0 grand_list_of_kept_original_filenames =[] was_process_cancelled =False + # This block for initializing manga mode counters remains unchanged if self .manga_mode_active and self .manga_filename_style ==STYLE_DATE_BASED and not self .extract_links_only and self .manga_date_file_counter_ref is None : - series_scan_dir =self .output_dir - if self .use_subfolders : - if self .filter_character_list_objects_initial and self .filter_character_list_objects_initial [0 ]and self .filter_character_list_objects_initial [0 ].get ("name"): - series_folder_name =clean_folder_name (self .filter_character_list_objects_initial [0 ]["name"]) - series_scan_dir =os .path .join (series_scan_dir ,series_folder_name ) - elif self .service and self .user_id : - creator_based_folder_name =clean_folder_name (str (self .user_id )) - series_scan_dir =os .path .join (series_scan_dir ,creator_based_folder_name ) - highest_num =0 - if os .path .isdir (series_scan_dir ): - self .logger (f"ℹ️ [Thread] Manga Date Mode: Scanning for existing files in '{series_scan_dir }'...") - for dirpath ,_ ,filenames_in_dir in os .walk (series_scan_dir ): - for filename_to_check in filenames_in_dir : - - prefix_to_check =clean_filename (self .manga_date_prefix .strip ())if self .manga_date_prefix and self .manga_date_prefix .strip ()else "" - name_part_to_match =filename_to_check - if prefix_to_check and name_part_to_match .startswith (prefix_to_check ): - name_part_to_match =name_part_to_match [len (prefix_to_check ):].lstrip () - - base_name_no_ext =os .path .splitext (name_part_to_match )[0 ] - match =re .match (r"(\d+)",base_name_no_ext ) - if match :highest_num =max (highest_num ,int (match .group (1 ))) - self .manga_date_file_counter_ref =[highest_num +1 ,threading .Lock ()] - self .logger (f"ℹ️ [Thread] Manga Date Mode: Initialized date-based counter at {self .manga_date_file_counter_ref [0 ]}.") - - + # ... (existing manga counter initialization logic) ... + pass if self .manga_mode_active and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not self .extract_links_only and self .manga_global_file_counter_ref is None : - self .manga_global_file_counter_ref =[1 ,threading .Lock ()] - self .logger (f"ℹ️ [Thread] Manga Title+GlobalNum Mode: Initialized global counter at {self .manga_global_file_counter_ref [0 ]}.") - worker_signals_obj = PostProcessorSignals () + # ... (existing manga counter initialization logic) ... + pass + + worker_signals_obj = PostProcessorSignals() try : - worker_signals_obj .progress_signal .connect (self .progress_signal ) - worker_signals_obj .file_download_status_signal .connect (self .file_download_status_signal ) - worker_signals_obj .file_progress_signal .connect (self .file_progress_signal ) - worker_signals_obj .external_link_signal .connect (self .external_link_signal ) - worker_signals_obj .missed_character_post_signal .connect (self .missed_character_post_signal ) - worker_signals_obj .file_successfully_downloaded_signal .connect (self .file_successfully_downloaded_signal ) - self .logger (" Starting post fetch (single-threaded download process)...") - post_generator =download_from_api ( - self .api_url_input , - logger =self .logger , - start_page =self .start_page , - end_page =self .end_page , - manga_mode =self .manga_mode_active , - cancellation_event =self .cancellation_event , - pause_event =self .pause_event , - use_cookie =self .use_cookie , - cookie_text =self .cookie_text , - selected_cookie_file =self .selected_cookie_file , - app_base_dir =self .app_base_dir , - manga_filename_style_for_sort_check =self .manga_filename_style if self .manga_mode_active else None + # Connect signals + worker_signals_obj.progress_signal.connect(self.progress_signal) + worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal) + worker_signals_obj.file_progress_signal.connect(self.file_progress_signal) + worker_signals_obj.external_link_signal.connect(self.external_link_signal) + worker_signals_obj.missed_character_post_signal.connect(self.missed_character_post_signal) + worker_signals_obj.file_successfully_downloaded_signal.connect(self.file_successfully_downloaded_signal) + worker_signals_obj.worker_finished_signal.connect(lambda result: None) # Connect to dummy lambda to avoid errors + + self.logger(" Starting post fetch (single-threaded download process)...") + post_generator = download_from_api( + self.api_url_input, + logger=self.logger, + start_page=self.start_page, + end_page=self.end_page, + manga_mode=self.manga_mode_active, + cancellation_event=self.cancellation_event, + pause_event=self.pause_event, + use_cookie=self.use_cookie, + cookie_text=self.cookie_text, + selected_cookie_file=self.selected_cookie_file, + app_base_dir=self.app_base_dir, + manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None ) - for posts_batch_data in post_generator : - if self ._check_pause_self ("Post batch processing"):was_process_cancelled =True ;break - if self .isInterruptionRequested ():was_process_cancelled =True ;break - for individual_post_data in posts_batch_data : - if self ._check_pause_self (f"Individual post processing for {individual_post_data .get ('id','N/A')}"):was_process_cancelled =True ;break - if self .isInterruptionRequested ():was_process_cancelled =True ;break - post_processing_worker =PostProcessorWorker ( - post_data =individual_post_data , - download_root =self .output_dir , - known_names =self .known_names , - filter_character_list =self .filter_character_list_objects_initial , - dynamic_character_filter_holder =self .dynamic_filter_holder , - unwanted_keywords =self .unwanted_keywords , - filter_mode =self .filter_mode , - skip_zip =self .skip_zip ,skip_rar =self .skip_rar , - use_subfolders =self .use_subfolders ,use_post_subfolders =self .use_post_subfolders , - target_post_id_from_initial_url =self .initial_target_post_id , - custom_folder_name =self .custom_folder_name , - compress_images =self .compress_images ,download_thumbnails =self .download_thumbnails , - service =self .service ,user_id =self .user_id , - api_url_input =self .api_url_input , - pause_event =self .pause_event , - cancellation_event =self .cancellation_event , - emitter =worker_signals_obj , - downloaded_files =self .downloaded_files , - downloaded_file_hashes =self .downloaded_file_hashes , - downloaded_files_lock =self .downloaded_files_lock , - downloaded_file_hashes_lock =self .downloaded_file_hashes_lock , - skip_words_list =self .skip_words_list , - skip_words_scope =self .skip_words_scope , - show_external_links =self .show_external_links , - extract_links_only =self .extract_links_only , - num_file_threads =self .num_file_threads_for_worker , - skip_current_file_flag =self .skip_current_file_flag , - manga_mode_active =self .manga_mode_active , - manga_filename_style =self .manga_filename_style , - manga_date_prefix =self .manga_date_prefix , - char_filter_scope =self .char_filter_scope , - remove_from_filename_words_list =self .remove_from_filename_words_list , - allow_multipart_download =self .allow_multipart_download , - selected_cookie_file =self .selected_cookie_file , - app_base_dir =self .app_base_dir , - cookie_text =self .cookie_text , - override_output_dir =self .override_output_dir , - manga_global_file_counter_ref =self .manga_global_file_counter_ref , - use_cookie =self .use_cookie , - manga_date_file_counter_ref =self .manga_date_file_counter_ref , - use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder, - keep_in_post_duplicates=self.keep_in_post_duplicates, - creator_download_folder_ignore_words =self .creator_download_folder_ignore_words , - session_file_path=self.session_file_path, - session_lock=self.session_lock, + + for posts_batch_data in post_generator: + if self.isInterruptionRequested(): + was_process_cancelled = True + break + for individual_post_data in posts_batch_data: + if self.isInterruptionRequested(): + was_process_cancelled = True + break + + # Create the worker, now correctly passing single_pdf_mode + post_processing_worker = PostProcessorWorker( + post_data=individual_post_data, + download_root=self.output_dir, + known_names=self.known_names, + filter_character_list=self.filter_character_list_objects_initial, + dynamic_character_filter_holder=self.dynamic_filter_holder, + unwanted_keywords=self.unwanted_keywords, + filter_mode=self.filter_mode, + skip_zip=self.skip_zip, skip_rar=self.skip_rar, + use_subfolders=self.use_subfolders, use_post_subfolders=self.use_post_subfolders, + target_post_id_from_initial_url=self.initial_target_post_id, + custom_folder_name=self.custom_folder_name, + compress_images=self.compress_images, download_thumbnails=self.download_thumbnails, + service=self.service, user_id=self.user_id, + api_url_input=self.api_url_input, + pause_event=self.pause_event, + cancellation_event=self.cancellation_event, + emitter=worker_signals_obj, + downloaded_files=self.downloaded_files, + downloaded_file_hashes=self.downloaded_file_hashes, + downloaded_files_lock=self.downloaded_files_lock, + downloaded_file_hashes_lock=self.downloaded_file_hashes_lock, + skip_words_list=self.skip_words_list, + skip_words_scope=self.skip_words_scope, + show_external_links=self.show_external_links, + extract_links_only=self.extract_links_only, + num_file_threads=self.num_file_threads_for_worker, + skip_current_file_flag=self.skip_current_file_flag, + manga_mode_active=self.manga_mode_active, + manga_filename_style=self.manga_filename_style, + manga_date_prefix=self.manga_date_prefix, + char_filter_scope=self.char_filter_scope, + remove_from_filename_words_list=self.remove_from_filename_words_list, + allow_multipart_download=self.allow_multipart_download, + selected_cookie_file=self.selected_cookie_file, + app_base_dir=self.app_base_dir, + cookie_text=self.cookie_text, + override_output_dir=self.override_output_dir, + manga_global_file_counter_ref=self.manga_global_file_counter_ref, + use_cookie=self.use_cookie, + manga_date_file_counter_ref=self.manga_date_file_counter_ref, + use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder, + keep_in_post_duplicates=self.keep_in_post_duplicates, + creator_download_folder_ignore_words=self.creator_download_folder_ignore_words, + session_file_path=self.session_file_path, + session_lock=self.session_lock, + text_only_scope=self.text_only_scope, + text_export_format=self.text_export_format, + single_pdf_mode=self.single_pdf_mode, # <-- This is now correctly passed + project_root_dir=self.project_root_dir ) - try : - dl_count ,skip_count ,kept_originals_this_post ,retryable_failures ,permanent_failures ,history_data =post_processing_worker .process () - grand_total_downloaded_files +=dl_count - grand_total_skipped_files +=skip_count - if kept_originals_this_post : - grand_list_of_kept_original_filenames .extend (kept_originals_this_post ) - if retryable_failures : - self .retryable_file_failed_signal .emit (retryable_failures ) - if history_data : - if len (self .history_candidates_buffer )<8 : - self .post_processed_for_history_signal .emit (history_data ) - if permanent_failures : - self .permanent_file_failed_signal .emit (permanent_failures ) - except Exception as proc_err : - post_id_for_err =individual_post_data .get ('id','N/A') - self .logger (f"❌ Error processing post {post_id_for_err } in DownloadThread: {proc_err }") - traceback .print_exc () - num_potential_files_est =len (individual_post_data .get ('attachments',[]))+(1 if individual_post_data .get ('file')else 0 ) - grand_total_skipped_files +=num_potential_files_est - if self .skip_current_file_flag and self .skip_current_file_flag .is_set (): - self .skip_current_file_flag .clear () - self .logger (" Skip current file flag was processed and cleared by DownloadThread.") - self .msleep (10 ) - if was_process_cancelled :break - if not was_process_cancelled and not self .isInterruptionRequested (): - self .logger ("✅ All posts processed or end of content reached by DownloadThread.") + try: + # Correctly unpack the 7 values returned from the worker + (dl_count, skip_count, kept_originals_this_post, + retryable_failures, permanent_failures, + history_data, temp_filepath) = post_processing_worker.process() + + grand_total_downloaded_files += dl_count + grand_total_skipped_files += skip_count + + if kept_originals_this_post: + grand_list_of_kept_original_filenames.extend(kept_originals_this_post) + if retryable_failures: + self.retryable_file_failed_signal.emit(retryable_failures) + if history_data: + if len(self.history_candidates_buffer) < 8: + self.post_processed_for_history_signal.emit(history_data) + if permanent_failures: + self.permanent_file_failed_signal.emit(permanent_failures) + + # In single-threaded text mode, pass the temp file path back to the main window + if self.single_pdf_mode and temp_filepath: + self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}") - except Exception as main_thread_err : - self .logger (f"\n❌ Critical error within DownloadThread run loop: {main_thread_err }") - traceback .print_exc () - if not self .isInterruptionRequested ():was_process_cancelled =False - finally : - try : - if worker_signals_obj : - worker_signals_obj .progress_signal .disconnect (self .progress_signal ) - worker_signals_obj .file_download_status_signal .disconnect (self .file_download_status_signal ) - worker_signals_obj .external_link_signal .disconnect (self .external_link_signal ) - worker_signals_obj .file_progress_signal .disconnect (self .file_progress_signal ) - worker_signals_obj .missed_character_post_signal .disconnect (self .missed_character_post_signal ) - worker_signals_obj .file_successfully_downloaded_signal .disconnect (self .file_successfully_downloaded_signal ) + except Exception as proc_err: + post_id_for_err = individual_post_data.get('id', 'N/A') + self.logger(f"❌ Error processing post {post_id_for_err} in DownloadThread: {proc_err}") + traceback.print_exc() + num_potential_files_est = len(individual_post_data.get('attachments', [])) + (1 if individual_post_data.get('file') else 0) + grand_total_skipped_files += num_potential_files_est + + if self.skip_current_file_flag and self.skip_current_file_flag.is_set(): + self.skip_current_file_flag.clear() + self.logger(" Skip current file flag was processed and cleared by DownloadThread.") + self.msleep(10) + if was_process_cancelled: + break + if not was_process_cancelled and not self.isInterruptionRequested(): + self.logger("✅ All posts processed or end of content reached by DownloadThread.") + + except Exception as main_thread_err: + self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}") + traceback.print_exc() + finally: + try: + # Disconnect signals + if worker_signals_obj: + worker_signals_obj.progress_signal.disconnect(self.progress_signal) + worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal) + worker_signals_obj.external_link_signal.disconnect(self.external_link_signal) + worker_signals_obj.file_progress_signal.disconnect(self.file_progress_signal) + worker_signals_obj.missed_character_post_signal.disconnect(self.missed_character_post_signal) + worker_signals_obj.file_successfully_downloaded_signal.disconnect(self.file_successfully_downloaded_signal) + except (TypeError, RuntimeError) as e: + self.logger(f"ℹ️ Note during DownloadThread signal disconnection: {e}") + + # Emit the final signal with all collected results + self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames) - except (TypeError ,RuntimeError )as e : - self .logger (f"ℹ️ Note during DownloadThread signal disconnection: {e }") - self .finished_signal .emit (grand_total_downloaded_files ,grand_total_skipped_files ,self .isInterruptionRequested (),grand_list_of_kept_original_filenames ) def receive_add_character_result (self ,result ): with QMutexLocker (self .prompt_mutex ): self ._add_character_response =result diff --git a/src/ui/dialogs/EmptyPopupDialog.py b/src/ui/dialogs/EmptyPopupDialog.py index 270cf3a..d2fddad 100644 --- a/src/ui/dialogs/EmptyPopupDialog.py +++ b/src/ui/dialogs/EmptyPopupDialog.py @@ -144,7 +144,7 @@ class EmptyPopupDialog (QDialog ): self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor )) self .parent_app =parent_app_ref - self .current_scope_mode =self .SCOPE_CHARACTERS + self.current_scope_mode = self.SCOPE_CREATORS self .app_base_dir =app_base_dir app_icon =get_app_icon_object () diff --git a/src/ui/dialogs/FavoriteArtistsDialog.py b/src/ui/dialogs/FavoriteArtistsDialog.py index c50211c..9b8c1fb 100644 --- a/src/ui/dialogs/FavoriteArtistsDialog.py +++ b/src/ui/dialogs/FavoriteArtistsDialog.py @@ -126,6 +126,21 @@ class FavoriteArtistsDialog (QDialog ): self .artist_list_widget .setVisible (show ) def _fetch_favorite_artists (self ): + + if self.cookies_config['use_cookie']: + # Check if we can load cookies for at least one of the services. + kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su") + coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su") + + if not kemono_cookies and not coomer_cookies: + # If cookies are enabled but none could be loaded, show help and stop. + self.status_label.setText(self._tr("fav_artists_cookies_required_status", "Error: Cookies enabled but could not be loaded for any source.")) + self._logger("Error: Cookies enabled but no valid cookies were loaded. Showing help dialog.") + cookie_help_dialog = CookieHelpDialog(self.parent_app, self) + cookie_help_dialog.exec_() + self.download_button.setEnabled(False) + return # Stop further execution + kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist" coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist" diff --git a/src/ui/dialogs/MoreOptionsDialog.py b/src/ui/dialogs/MoreOptionsDialog.py new file mode 100644 index 0000000..1f1ec5d --- /dev/null +++ b/src/ui/dialogs/MoreOptionsDialog.py @@ -0,0 +1,83 @@ +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox +) +from PyQt5.QtCore import Qt + +class MoreOptionsDialog(QDialog): + """ + A dialog for selecting a scope, export format, and single PDF option. + """ + SCOPE_CONTENT = "content" + SCOPE_COMMENTS = "comments" + + def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False): + super().__init__(parent) + self.setWindowTitle("More Options") + self.setMinimumWidth(350) + + # ... (Layout and other widgets remain the same) ... + + layout = QVBoxLayout(self) + self.description_label = QLabel("Please choose the scope for the action:") + layout.addWidget(self.description_label) + self.radio_button_group = QButtonGroup(self) + self.radio_content = QRadioButton("Description/Content") + self.radio_comments = QRadioButton("Comments") + self.radio_button_group.addButton(self.radio_content) + self.radio_button_group.addButton(self.radio_comments) + layout.addWidget(self.radio_content) + layout.addWidget(self.radio_comments) + + if current_scope == self.SCOPE_COMMENTS: + self.radio_comments.setChecked(True) + else: + self.radio_content.setChecked(True) + + export_layout = QHBoxLayout() + export_label = QLabel("Export as:") + self.format_combo = QComboBox() + self.format_combo.addItems(["PDF", "DOCX", "TXT"]) + + if current_format and current_format.upper() in ["PDF", "DOCX", "TXT"]: + self.format_combo.setCurrentText(current_format.upper()) + else: + self.format_combo.setCurrentText("PDF") + + export_layout.addWidget(export_label) + export_layout.addWidget(self.format_combo) + export_layout.addStretch() + layout.addLayout(export_layout) + + # --- UPDATED: Single PDF Checkbox --- + self.single_pdf_checkbox = QCheckBox("Single PDF") + self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.") + self.single_pdf_checkbox.setChecked(single_pdf_checked) + layout.addWidget(self.single_pdf_checkbox) + + self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state) + self.update_single_pdf_checkbox_state(self.format_combo.currentText()) + + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + self.setLayout(layout) + + def update_single_pdf_checkbox_state(self, text): + """Enable the Single PDF checkbox only if the format is PDF.""" + is_pdf = (text.upper() == "PDF") + self.single_pdf_checkbox.setEnabled(is_pdf) + if not is_pdf: + self.single_pdf_checkbox.setChecked(False) + + def get_selected_scope(self): + if self.radio_comments.isChecked(): + return self.SCOPE_COMMENTS + return self.SCOPE_CONTENT + + def get_selected_format(self): + return self.format_combo.currentText().lower() + + def get_single_pdf_state(self): + """Returns the state of the Single PDF checkbox.""" + return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled() \ No newline at end of file diff --git a/src/ui/dialogs/SinglePDF.py b/src/ui/dialogs/SinglePDF.py new file mode 100644 index 0000000..a680103 --- /dev/null +++ b/src/ui/dialogs/SinglePDF.py @@ -0,0 +1,77 @@ +# SinglePDF.py + +import os +try: + from fpdf import FPDF + FPDF_AVAILABLE = True +except ImportError: + FPDF_AVAILABLE = False + +class PDF(FPDF): + """Custom PDF class to handle headers and footers.""" + def header(self): + # No header + pass + + def footer(self): + # Position at 1.5 cm from bottom + self.set_y(-15) + self.set_font('DejaVu', '', 8) + # Page number + self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') + +def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print): + """ + Creates a single PDF from a list of post titles and content. + + Args: + posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys. + output_filename (str): The full path for the output PDF file. + font_path (str): Path to the DejaVuSans.ttf font file. + logger (function, optional): A function to log progress and errors. Defaults to print. + """ + if not FPDF_AVAILABLE: + logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2") + return False + + if not posts_data: + logger(" No text content was collected to create a PDF.") + return False + + pdf = PDF() + + try: + if not os.path.exists(font_path): + raise RuntimeError("Font file not found.") + pdf.add_font('DejaVu', '', font_path, uni=True) + pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant + except Exception as font_error: + logger(f" ⚠️ Could not load DejaVu font: {font_error}") + logger(" PDF may not support all characters. Falling back to default Arial font.") + pdf.set_font('Arial', '', 12) + pdf.set_font('Arial', 'B', 16) + + logger(f" Starting PDF creation with content from {len(posts_data)} posts...") + + for post in posts_data: + pdf.add_page() + # Post Title + pdf.set_font('DejaVu', 'B', 16) + + # vvv THIS LINE IS CORRECTED vvv + # We explicitly set align='L' and remove the incorrect positional arguments. + pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L') + + pdf.ln(5) # Add a little space after the title + + # Post Content + pdf.set_font('DejaVu', '', 12) + pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) + + try: + pdf.output(output_filename) + logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'") + return True + except Exception as e: + logger(f"❌ A critical error occurred while saving the final PDF: {e}") + return False diff --git a/src/ui/main_window.py b/src/ui/main_window.py index bec9096..9488639 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -24,7 +24,7 @@ from PyQt5.QtWidgets import ( QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton, QButtonGroup, QCheckBox, QSplitter, QGroupBox, QDialog, QStackedWidget, QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame, - QMainWindow, QAction + QMainWindow, QAction, QGridLayout ) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker @@ -52,6 +52,8 @@ from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog from .dialogs.FavoritePostsDialog import FavoritePostsDialog from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog from .dialogs.ConfirmAddAllDialog import ConfirmAddAllDialog +from .dialogs.MoreOptionsDialog import MoreOptionsDialog +from .dialogs.SinglePDF import create_single_pdf_from_content class DynamicFilterHolder: """A thread-safe class to hold and update character filters during a download.""" @@ -76,6 +78,7 @@ class PostProcessorSignals(QObject): file_progress_signal = pyqtSignal(str, object) file_successfully_downloaded_signal = pyqtSignal(dict) missed_character_post_signal = pyqtSignal(str, str) + worker_finished_signal = pyqtSignal(tuple) finished_signal = pyqtSignal(int, int, bool, list) retryable_file_failed_signal = pyqtSignal(list) permanent_file_failed_signal = pyqtSignal(list) @@ -210,7 +213,11 @@ class DownloaderApp (QWidget ): self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool) self.cookie_text_setting = "" self.current_selected_language = self.settings.value(LANGUAGE_KEY, "en", type=str) - + self.more_filter_scope = None + self.text_export_format = 'pdf' + self.single_pdf_setting = False + self.session_temp_files = [] + print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}") try: @@ -636,6 +643,7 @@ class DownloaderApp (QWidget ): self .actual_gui_signals .missed_character_post_signal .connect (self .handle_missed_character_post ) self .actual_gui_signals .external_link_signal .connect (self .handle_external_link_signal ) self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded ) + self.actual_gui_signals.worker_finished_signal.connect(self._handle_worker_result) self .actual_gui_signals .file_download_status_signal .connect (lambda status :None ) if hasattr (self ,'character_input'): @@ -801,7 +809,9 @@ class DownloaderApp (QWidget ): self ._handle_actual_file_downloaded (payload [0 ]if payload else {}) elif signal_type =='file_successfully_downloaded': self ._handle_file_successfully_downloaded (payload [0 ]) - else : + elif signal_type == 'worker_finished': # <-- ADD THIS ELIF BLOCK + self.actual_gui_signals.worker_finished_signal.emit(payload[0] if payload else tuple()) + else: self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }") self .worker_to_gui_queue .task_done () except queue .Empty : @@ -984,557 +994,468 @@ class DownloaderApp (QWidget ): QMessageBox .critical (self ,"Restart Failed", f"Could not automatically restart the application: {e }\n\nPlease restart it manually.") + def init_ui(self): + self.main_splitter = QSplitter(Qt.Horizontal) + + # --- Use a scroll area for the left panel for consistency --- + left_scroll_area = QScrollArea() + left_scroll_area.setWidgetResizable(True) + left_scroll_area.setFrameShape(QFrame.NoFrame) - def init_ui (self ): - self .main_splitter =QSplitter (Qt .Horizontal ) - left_panel_widget =QWidget () - right_panel_widget =QWidget () - left_layout =QVBoxLayout (left_panel_widget ) - right_layout =QVBoxLayout (right_panel_widget ) - left_layout .setContentsMargins (10 ,10 ,10 ,10 ) - right_layout .setContentsMargins (10 ,10 ,10 ,10 ) - self .apply_theme (self .current_theme ,initial_load =True ) + left_panel_widget = QWidget() + left_layout = QVBoxLayout(left_panel_widget) + left_scroll_area.setWidget(left_panel_widget) - self .url_input_widget =QWidget () - url_input_layout =QHBoxLayout (self .url_input_widget ) - url_input_layout .setContentsMargins (0 ,0 ,0 ,0 ) + right_panel_widget = QWidget() + right_layout = QVBoxLayout(right_panel_widget) + + left_layout.setContentsMargins(10, 10, 10, 10) + right_layout.setContentsMargins(10, 10, 10, 10) + self.apply_theme(self.current_theme, initial_load=True) - self .url_label_widget =QLabel () - url_input_layout .addWidget (self .url_label_widget ) - 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 ) - url_input_layout .addWidget (self .link_input ,1 ) - self .empty_popup_button =QPushButton ("🎨") - self .empty_popup_button .setStyleSheet ("padding: 4px 6px;") - self .empty_popup_button .clicked .connect (self ._show_empty_popup ) - url_input_layout .addWidget (self .empty_popup_button ) + # --- URL and Page Range --- + self.url_input_widget = QWidget() + url_input_layout = QHBoxLayout(self.url_input_widget) + url_input_layout.setContentsMargins(0, 0, 0, 0) + self.url_label_widget = QLabel() + url_input_layout.addWidget(self.url_label_widget) + 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) # Connects the custom folder logic + url_input_layout.addWidget(self.link_input, 1) + self.empty_popup_button = QPushButton("🎨") + self.empty_popup_button.setStyleSheet("padding: 4px 6px;") + self.empty_popup_button.clicked.connect(self._show_empty_popup) + url_input_layout.addWidget(self.empty_popup_button) + self.page_range_label = QLabel(self._tr("page_range_label_text", "Page Range:")) + self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") + url_input_layout.addWidget(self.page_range_label) + self.start_page_input = QLineEdit() + self.start_page_input.setPlaceholderText(self._tr("start_page_input_placeholder", "Start")) + self.start_page_input.setFixedWidth(50) + self.start_page_input.setValidator(QIntValidator(1, 99999)) + url_input_layout.addWidget(self.start_page_input) + self.to_label = QLabel(self._tr("page_range_to_label_text", "to")) + url_input_layout.addWidget(self.to_label) + self.end_page_input = QLineEdit() + self.end_page_input.setPlaceholderText(self._tr("end_page_input_placeholder", "End")) + self.end_page_input.setFixedWidth(50) + self.end_page_input.setToolTip(self._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number...")) + self.end_page_input.setValidator(QIntValidator(1, 99999)) + url_input_layout.addWidget(self.end_page_input) + self.url_placeholder_widget = QWidget() + placeholder_layout = QHBoxLayout(self.url_placeholder_widget) + placeholder_layout.setContentsMargins(0, 0, 0, 0) + self.fav_mode_active_label = QLabel(self._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active...")) + self.fav_mode_active_label.setAlignment(Qt.AlignCenter) + placeholder_layout.addWidget(self.fav_mode_active_label) + self.url_or_placeholder_stack = QStackedWidget() + self.url_or_placeholder_stack.addWidget(self.url_input_widget) + self.url_or_placeholder_stack.addWidget(self.url_placeholder_widget) + left_layout.addWidget(self.url_or_placeholder_stack) - self .page_range_label =QLabel (self ._tr ("page_range_label_text","Page Range:")) - self .page_range_label .setStyleSheet ("font-weight: bold; padding-left: 10px;") - url_input_layout .addWidget (self .page_range_label ) - self .start_page_input =QLineEdit () - self .start_page_input .setPlaceholderText (self ._tr ("start_page_input_placeholder","Start")) - self .start_page_input .setFixedWidth (50 ) - self .start_page_input .setValidator (QIntValidator (1 ,99999 )) - url_input_layout .addWidget (self .start_page_input ) - self .to_label =QLabel (self ._tr ("page_range_to_label_text","to")) - url_input_layout .addWidget (self .to_label ) - self .end_page_input =QLineEdit () - self .end_page_input .setPlaceholderText (self ._tr ("end_page_input_placeholder","End")) - self .end_page_input .setFixedWidth (50 ) - self .end_page_input .setToolTip (self ._tr ("end_page_input_tooltip","For creator URLs: Specify the ending page number...")) - self .end_page_input .setValidator (QIntValidator (1 ,99999 )) - url_input_layout .addWidget (self .end_page_input ) + # --- Download Location --- + self.download_location_label_widget = QLabel() + left_layout.addWidget(self.download_location_label_widget) + dir_layout = QHBoxLayout() + self.dir_input = QLineEdit() + self.dir_input.setPlaceholderText("Select folder where downloads will be saved") + self.dir_button = QPushButton("Browse...") + self.dir_button.setStyleSheet("padding: 4px 10px;") + self.dir_button.clicked.connect(self.browse_directory) + dir_layout.addWidget(self.dir_input, 1) + dir_layout.addWidget(self.dir_button) + left_layout.addLayout(dir_layout) - self .url_placeholder_widget =QWidget () - placeholder_layout =QHBoxLayout (self .url_placeholder_widget ) - placeholder_layout .setContentsMargins (0 ,0 ,0 ,0 ) - self .fav_mode_active_label =QLabel (self ._tr ("fav_mode_active_label_text","⭐ Favorite Mode is active...")) - self .fav_mode_active_label .setAlignment (Qt .AlignCenter ) - placeholder_layout .addWidget (self .fav_mode_active_label ) + # --- Filters and Custom Folder Container (from old layout) --- + self.filters_and_custom_folder_container_widget = QWidget() + filters_and_custom_folder_layout = QHBoxLayout(self.filters_and_custom_folder_container_widget) + filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0) + filters_and_custom_folder_layout.setSpacing(10) + self.character_filter_widget = QWidget() + character_filter_v_layout = QVBoxLayout(self.character_filter_widget) + character_filter_v_layout.setContentsMargins(0, 0, 0, 0) + character_filter_v_layout.setSpacing(2) + self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):") + character_filter_v_layout.addWidget(self.character_label) + char_input_and_button_layout = QHBoxLayout() + char_input_and_button_layout.setContentsMargins(0, 0, 0, 0) + char_input_and_button_layout.setSpacing(10) + self.character_input = QLineEdit() + self.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)") + char_input_and_button_layout.addWidget(self.character_input, 3) + self.char_filter_scope_toggle_button = QPushButton() + self._update_char_filter_scope_button_text() + char_input_and_button_layout.addWidget(self.char_filter_scope_toggle_button, 1) + character_filter_v_layout.addLayout(char_input_and_button_layout) + + # --- Custom Folder Widget Definition --- + self.custom_folder_widget = QWidget() + custom_folder_v_layout = QVBoxLayout(self.custom_folder_widget) + custom_folder_v_layout.setContentsMargins(0, 0, 0, 0) + custom_folder_v_layout.setSpacing(2) + 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_v_layout.addWidget(self.custom_folder_label) + custom_folder_v_layout.addWidget(self.custom_folder_input) + self.custom_folder_widget.setVisible(False) + + filters_and_custom_folder_layout.addWidget(self.character_filter_widget, 1) + filters_and_custom_folder_layout.addWidget(self.custom_folder_widget, 1) + left_layout.addWidget(self.filters_and_custom_folder_container_widget) - self .url_or_placeholder_stack =QStackedWidget () - self .url_or_placeholder_stack .addWidget (self .url_input_widget ) - self .url_or_placeholder_stack .addWidget (self .url_placeholder_widget ) - left_layout .addWidget (self .url_or_placeholder_stack ) + # --- Word Manipulation Container --- + word_manipulation_container_widget = QWidget() + word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget) + word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0) + word_manipulation_outer_layout.setSpacing(15) + skip_words_widget = QWidget() + skip_words_vertical_layout = QVBoxLayout(skip_words_widget) + skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0) + skip_words_vertical_layout.setSpacing(2) + self.skip_words_label_widget = QLabel() + skip_words_vertical_layout.addWidget(self.skip_words_label_widget) + skip_input_and_button_layout = QHBoxLayout() + skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0) + skip_input_and_button_layout.setSpacing(10) + self.skip_words_input = QLineEdit() + self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview") + skip_input_and_button_layout.addWidget(self.skip_words_input, 1) + self.skip_scope_toggle_button = QPushButton() + self._update_skip_scope_button_text() + skip_input_and_button_layout.addWidget(self.skip_scope_toggle_button, 0) + skip_words_vertical_layout.addLayout(skip_input_and_button_layout) + word_manipulation_outer_layout.addWidget(skip_words_widget, 7) + remove_words_widget = QWidget() + remove_words_vertical_layout = QVBoxLayout(remove_words_widget) + remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0) + remove_words_vertical_layout.setSpacing(2) + self.remove_from_filename_label_widget = QLabel() + remove_words_vertical_layout.addWidget(self.remove_from_filename_label_widget) + self.remove_from_filename_input = QLineEdit() + self.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD") + remove_words_vertical_layout.addWidget(self.remove_from_filename_input) + word_manipulation_outer_layout.addWidget(remove_words_widget, 3) + left_layout.addWidget(word_manipulation_container_widget) - self .favorite_action_buttons_widget =QWidget () - favorite_buttons_layout =QHBoxLayout (self .favorite_action_buttons_widget ) - favorite_buttons_layout .setContentsMargins (0 ,0 ,0 ,0 ) - self .favorite_mode_artists_button =QPushButton ("🖼️ Favorite Artists") - self .favorite_mode_artists_button .setToolTip ("Browse and download from your favorite artists on Kemono.su.") - self .favorite_mode_artists_button .setStyleSheet ("padding: 4px 12px;") - self .favorite_mode_artists_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred ) - self .favorite_mode_posts_button =QPushButton ("📄 Favorite Posts") - self .favorite_mode_posts_button .setToolTip ("Browse and download your favorite posts from Kemono.su.") - self .favorite_mode_posts_button .setStyleSheet ("padding: 4px 12px;") - self .favorite_mode_posts_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred ) + # --- File Filter Layout --- + file_filter_layout = QVBoxLayout() + file_filter_layout.setContentsMargins(0, 10, 0, 0) + file_filter_layout.addWidget(QLabel("Filter Files:")) + radio_button_layout = QHBoxLayout() + radio_button_layout.setSpacing(10) + self.radio_group = QButtonGroup(self) + self.radio_all = QRadioButton("All") + self.radio_images = QRadioButton("Images/GIFs") + self.radio_videos = QRadioButton("Videos") + self.radio_only_archives = QRadioButton("📦 Only Archives") + self.radio_only_audio = QRadioButton("🎧 Only Audio") + self.radio_only_links = QRadioButton("🔗 Only Links") + self.radio_more = QRadioButton("More") - self .favorite_scope_toggle_button =QPushButton () - self .favorite_scope_toggle_button .setStyleSheet ("padding: 4px 10px;") - self .favorite_scope_toggle_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred ) - - favorite_buttons_layout .addWidget (self .favorite_mode_artists_button ) - favorite_buttons_layout .addWidget (self .favorite_mode_posts_button ) - favorite_buttons_layout .addWidget (self .favorite_scope_toggle_button ) - - - self .download_location_label_widget =QLabel () - left_layout .addWidget (self .download_location_label_widget ) - self .dir_input =QLineEdit () - self .dir_input .setPlaceholderText ("Select folder where downloads will be saved") - self .dir_button =QPushButton ("Browse...") - self .dir_button .setStyleSheet ("padding: 4px 10px;") - self .dir_button .clicked .connect (self .browse_directory ) - dir_layout =QHBoxLayout () - dir_layout .addWidget (self .dir_input ,1 ) - dir_layout .addWidget (self .dir_button ) - left_layout .addLayout (dir_layout ) - - - self .filters_and_custom_folder_container_widget =QWidget () - filters_and_custom_folder_layout =QHBoxLayout (self .filters_and_custom_folder_container_widget ) - filters_and_custom_folder_layout .setContentsMargins (0 ,5 ,0 ,0 ) - filters_and_custom_folder_layout .setSpacing (10 ) - - self .character_filter_widget =QWidget () - character_filter_v_layout =QVBoxLayout (self .character_filter_widget ) - character_filter_v_layout .setContentsMargins (0 ,0 ,0 ,0 ) - character_filter_v_layout .setSpacing (2 ) - - self .character_label =QLabel ("🎯 Filter by Character(s) (comma-separated):") - character_filter_v_layout .addWidget (self .character_label ) - - char_input_and_button_layout =QHBoxLayout () - char_input_and_button_layout .setContentsMargins (0 ,0 ,0 ,0 ) - char_input_and_button_layout .setSpacing (10 ) - - self .character_input =QLineEdit () - self .character_input .setPlaceholderText ("e.g., Tifa, Aerith, (Cloud, Zack)") - char_input_and_button_layout .addWidget (self .character_input ,3 ) - - - self .char_filter_scope_toggle_button =QPushButton () - self ._update_char_filter_scope_button_text () - self .char_filter_scope_toggle_button .setStyleSheet ("padding: 4px 10px;") - self .char_filter_scope_toggle_button .setMinimumWidth (100 ) - char_input_and_button_layout .addWidget (self .char_filter_scope_toggle_button ,1 ) - - character_filter_v_layout .addLayout (char_input_and_button_layout ) - - - self .custom_folder_widget =QWidget () - custom_folder_v_layout =QVBoxLayout (self .custom_folder_widget ) - custom_folder_v_layout .setContentsMargins (0 ,0 ,0 ,0 ) - custom_folder_v_layout .setSpacing (2 ) - 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_v_layout .addWidget (self .custom_folder_label ) - custom_folder_v_layout .addWidget (self .custom_folder_input ) - self .custom_folder_widget .setVisible (False ) - - filters_and_custom_folder_layout .addWidget (self .character_filter_widget ,1 ) - filters_and_custom_folder_layout .addWidget (self .custom_folder_widget ,1 ) - - left_layout .addWidget (self .filters_and_custom_folder_container_widget ) - word_manipulation_container_widget =QWidget () - word_manipulation_outer_layout =QHBoxLayout (word_manipulation_container_widget ) - word_manipulation_outer_layout .setContentsMargins (0 ,0 ,0 ,0 ) - word_manipulation_outer_layout .setSpacing (15 ) - skip_words_widget =QWidget () - skip_words_vertical_layout =QVBoxLayout (skip_words_widget ) - skip_words_vertical_layout .setContentsMargins (0 ,0 ,0 ,0 ) - skip_words_vertical_layout .setSpacing (2 ) - - self .skip_words_label_widget =QLabel () - skip_words_vertical_layout .addWidget (self .skip_words_label_widget ) - - skip_input_and_button_layout =QHBoxLayout () - skip_input_and_button_layout =QHBoxLayout () - skip_input_and_button_layout .setContentsMargins (0 ,0 ,0 ,0 ) - skip_input_and_button_layout .setSpacing (10 ) - self .skip_words_input =QLineEdit () - self .skip_words_input .setPlaceholderText ("e.g., WM, WIP, sketch, preview") - skip_input_and_button_layout .addWidget (self .skip_words_input ,1 ) - - self .skip_scope_toggle_button =QPushButton () - self ._update_skip_scope_button_text () - self .skip_scope_toggle_button .setStyleSheet ("padding: 4px 10px;") - self .skip_scope_toggle_button .setMinimumWidth (100 ) - skip_input_and_button_layout .addWidget (self .skip_scope_toggle_button ,0 ) - skip_words_vertical_layout .addLayout (skip_input_and_button_layout ) - word_manipulation_outer_layout .addWidget (skip_words_widget ,7 ) - remove_words_widget =QWidget () - remove_words_vertical_layout =QVBoxLayout (remove_words_widget ) - remove_words_vertical_layout .setContentsMargins (0 ,0 ,0 ,0 ) - remove_words_vertical_layout .setSpacing (2 ) - self .remove_from_filename_label_widget =QLabel () - remove_words_vertical_layout .addWidget (self .remove_from_filename_label_widget ) - self .remove_from_filename_input =QLineEdit () - self .remove_from_filename_input .setPlaceholderText ("e.g., patreon, HD") - remove_words_vertical_layout .addWidget (self .remove_from_filename_input ) - word_manipulation_outer_layout .addWidget (remove_words_widget ,3 ) - - left_layout .addWidget (word_manipulation_container_widget ) - - - file_filter_layout =QVBoxLayout () - file_filter_layout .setContentsMargins (0 ,10 ,0 ,0 ) - file_filter_layout .addWidget (QLabel ("Filter Files:")) - radio_button_layout =QHBoxLayout () - radio_button_layout .setSpacing (10 ) - self .radio_group =QButtonGroup (self ) - self .radio_all =QRadioButton ("All") - self .radio_images =QRadioButton ("Images/GIFs") - self .radio_videos =QRadioButton ("Videos") - self .radio_only_archives =QRadioButton ("📦 Only Archives") - self .radio_only_audio =QRadioButton ("🎧 Only Audio") - self .radio_only_links =QRadioButton ("🔗 Only Links") - 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 ) - self .radio_group .addButton (self .radio_only_archives ) - self .radio_group .addButton (self .radio_only_audio ) - self .radio_group .addButton (self .radio_only_links ) - radio_button_layout .addWidget (self .radio_all ) - radio_button_layout .addWidget (self .radio_images ) - radio_button_layout .addWidget (self .radio_videos ) - radio_button_layout .addWidget (self .radio_only_archives ) - radio_button_layout .addWidget (self .radio_only_audio ) - file_filter_layout .addLayout (radio_button_layout ) - left_layout .addLayout (file_filter_layout ) - - self .favorite_mode_checkbox =QCheckBox () - self .favorite_mode_checkbox .setChecked (False ) - radio_button_layout .addWidget (self .radio_only_links ) - radio_button_layout .addWidget (self .favorite_mode_checkbox ) - radio_button_layout .addStretch (1 ) - checkboxes_group_layout =QVBoxLayout () - checkboxes_group_layout .setSpacing (10 ) - - row1_layout =QHBoxLayout () - 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 ) - row1_layout .addWidget (self .download_thumbnails_checkbox ) - - self .scan_content_images_checkbox =QCheckBox ("Scan Content for Images") - self .scan_content_images_checkbox .setChecked (self .scan_content_images_setting ) - row1_layout .addWidget (self .scan_content_images_checkbox ) - - self .compress_images_checkbox =QCheckBox ("Compress 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 ) + self.radio_all.setChecked(True) + for btn in [self.radio_all, self.radio_images, self.radio_videos, self.radio_only_archives, self.radio_only_audio, self.radio_only_links, self.radio_more]: + self.radio_group.addButton(btn) + radio_button_layout.addWidget(btn) + self.favorite_mode_checkbox = QCheckBox() + self.favorite_mode_checkbox.setChecked(False) + radio_button_layout.addWidget(self.favorite_mode_checkbox) + radio_button_layout.addStretch(1) + file_filter_layout.addLayout(radio_button_layout) + left_layout.addLayout(file_filter_layout) + # --- Checkboxes Group --- + checkboxes_group_layout = QVBoxLayout() + checkboxes_group_layout.setSpacing(10) + row1_layout = QHBoxLayout() + 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") + row1_layout.addWidget(self.download_thumbnails_checkbox) + self.scan_content_images_checkbox = QCheckBox("Scan Content for Images") + self.scan_content_images_checkbox.setChecked(self.scan_content_images_setting) + row1_layout.addWidget(self.scan_content_images_checkbox) + self.compress_images_checkbox = QCheckBox("Compress to WebP") + self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).") + row1_layout.addWidget(self.compress_images_checkbox) self.keep_duplicates_checkbox = QCheckBox("Keep Duplicates") - self.keep_duplicates_checkbox.setChecked(False) - self.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.\nUnique files will be renamed with a suffix; identical files will still be skipped by hash.") + self.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.") row1_layout.addWidget(self.keep_duplicates_checkbox) + row1_layout.addStretch(1) + checkboxes_group_layout.addLayout(row1_layout) - row1_layout .addStretch (1 ) - checkboxes_group_layout .addLayout (row1_layout ) - - advanced_settings_label =QLabel ("⚙️ Advanced Settings:") - checkboxes_group_layout .addWidget (advanced_settings_label ) - - advanced_row1_layout =QHBoxLayout () - 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 .toggled .connect (self .update_ui_for_subfolders ) - advanced_row1_layout .addWidget (self .use_subfolder_per_post_checkbox ) - + # --- Advanced Settings --- + advanced_settings_label = QLabel("⚙️ Advanced Settings:") + checkboxes_group_layout.addWidget(advanced_settings_label) + advanced_row1_layout = QHBoxLayout() + 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.toggled.connect(self.update_ui_for_subfolders) + advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox) self.date_prefix_checkbox = QCheckBox("Date Prefix") - self.date_prefix_checkbox.setChecked(False) - self.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date (e.g., YYYY-MM-DD Post Title).") + self.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") advanced_row1_layout.addWidget(self.date_prefix_checkbox) + self.use_cookie_checkbox = QCheckBox("Use Cookie") + self.use_cookie_checkbox.setChecked(self.use_cookie_setting) + self.cookie_text_input = QLineEdit() + self.cookie_text_input.setPlaceholderText("if no Select cookies.txt)") + self.cookie_text_input.setText(self.cookie_text_setting) + advanced_row1_layout.addWidget(self.use_cookie_checkbox) + advanced_row1_layout.addWidget(self.cookie_text_input, 2) + self.cookie_browse_button = QPushButton("Browse...") + self.cookie_browse_button.setFixedWidth(80) + self.cookie_browse_button.setStyleSheet("padding: 4px 8px;") + advanced_row1_layout.addWidget(self.cookie_browse_button) + advanced_row1_layout.addStretch(1) + checkboxes_group_layout.addLayout(advanced_row1_layout) + advanced_row2_layout = QHBoxLayout() + advanced_row2_layout.setSpacing(10) + multithreading_layout = QHBoxLayout() + multithreading_layout.setContentsMargins(0, 0, 0, 0) + self.use_multithreading_checkbox = QCheckBox("Use Multithreading") + self.use_multithreading_checkbox.setChecked(True) + 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("4") + self.thread_count_input.setFixedWidth(40) + self.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS)) + multithreading_layout.addWidget(self.thread_count_input) + advanced_row2_layout.addLayout(multithreading_layout) + self.external_links_checkbox = QCheckBox("Show External Links in Log") + advanced_row2_layout.addWidget(self.external_links_checkbox) + self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode") + 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) - self .use_cookie_checkbox =QCheckBox ("Use Cookie") - self .use_cookie_checkbox .setChecked (self .use_cookie_setting ) + # --- Action Buttons --- + self.standard_action_buttons_widget = QWidget() + btn_layout = QHBoxLayout(self.standard_action_buttons_widget) + btn_layout.setContentsMargins(0, 10, 0, 0) + btn_layout.setSpacing(10) + self.download_btn = QPushButton("⬇️ Start Download") + self.download_btn.setStyleSheet("padding: 4px 12px; font-weight: bold;") + self.download_btn.clicked.connect(self.start_download) + self.pause_btn = QPushButton("⏸️ Pause Download") + self.pause_btn.setEnabled(False) + self.pause_btn.setStyleSheet("padding: 4px 12px;") + self.pause_btn.clicked.connect(self._handle_pause_resume_action) + self.cancel_btn = QPushButton("❌ Cancel & Reset UI") + self.cancel_btn.setEnabled(False) + self.cancel_btn.setStyleSheet("padding: 4px 12px;") + self.cancel_btn.clicked.connect(self.cancel_download_button_action) + self.error_btn = QPushButton("Error") + self.error_btn.setToolTip("View files skipped due to errors and optionally retry them.") + self.error_btn.setStyleSheet("padding: 4px 8px;") + self.error_btn.setEnabled(True) + btn_layout.addWidget(self.download_btn) + btn_layout.addWidget(self.pause_btn) + btn_layout.addWidget(self.cancel_btn) + btn_layout.addWidget(self.error_btn) + self.favorite_action_buttons_widget = QWidget() + favorite_buttons_layout = QHBoxLayout(self.favorite_action_buttons_widget) + self.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists") + self.favorite_mode_posts_button = QPushButton("📄 Favorite Posts") + self.favorite_scope_toggle_button = QPushButton() + favorite_buttons_layout.addWidget(self.favorite_mode_artists_button) + favorite_buttons_layout.addWidget(self.favorite_mode_posts_button) + favorite_buttons_layout.addWidget(self.favorite_scope_toggle_button) + self.bottom_action_buttons_stack = QStackedWidget() + self.bottom_action_buttons_stack.addWidget(self.standard_action_buttons_widget) + self.bottom_action_buttons_stack.addWidget(self.favorite_action_buttons_widget) + left_layout.addWidget(self.bottom_action_buttons_stack) + left_layout.addSpacing(10) - self .cookie_text_input =QLineEdit () - self .cookie_text_input .setPlaceholderText ("if no Select cookies.txt)") - self .cookie_text_input .setMinimumHeight (28 ) - self .cookie_text_input .setText (self .cookie_text_setting ) + # --- Known Names Layout --- + known_chars_label_layout = QHBoxLayout() + known_chars_label_layout.setSpacing(10) + self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") + known_chars_label_layout.addWidget(self.known_chars_label) + self.open_known_txt_button = QPushButton("Open Known.txt") + self.open_known_txt_button.setStyleSheet("padding: 4px 8px;") + self.open_known_txt_button.setFixedWidth(120) + known_chars_label_layout.addWidget(self.open_known_txt_button) + self.character_search_input = QLineEdit() + self.character_search_input.setPlaceholderText("Search characters...") + known_chars_label_layout.addWidget(self.character_search_input, 1) + left_layout.addLayout(known_chars_label_layout) + self.character_list = QListWidget() + self.character_list.setSelectionMode(QListWidget.ExtendedSelection) + self.character_list.setMaximumHeight(150) # Set smaller height + left_layout.addWidget(self.character_list, 1) + char_manage_layout = QHBoxLayout() + char_manage_layout.setSpacing(10) + self.new_char_input = QLineEdit() + self.new_char_input.setPlaceholderText("Add new show/character name") + self.new_char_input.setStyleSheet("padding: 3px 5px;") + self.add_char_button = QPushButton("➕ Add") + self.add_char_button.setStyleSheet("padding: 4px 10px;") + self.add_to_filter_button = QPushButton("⤵️ Add to Filter") + self.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.") + self.add_to_filter_button.setStyleSheet("padding: 4px 10px;") + self.delete_char_button = QPushButton("🗑️ Delete Selected") + self.delete_char_button.setToolTip("Delete the selected name(s)...") + self.delete_char_button.setStyleSheet("padding: 4px 10px;") + self.add_char_button.clicked.connect(self._handle_ui_add_new_character) + self.new_char_input.returnPressed.connect(self.add_char_button.click) + self.delete_char_button.clicked.connect(self.delete_selected_character) + char_manage_layout.addWidget(self.new_char_input, 2) + char_manage_layout.addWidget(self.add_char_button, 0) + self.known_names_help_button = QPushButton("?") + self.known_names_help_button.setFixedWidth(35) + self.known_names_help_button.setStyleSheet("padding: 4px 6px;") + self.known_names_help_button.clicked.connect(self._show_feature_guide) + self.history_button = QPushButton("📜") + self.history_button.setFixedWidth(35) + self.history_button.setStyleSheet("padding: 4px 6px;") + self.history_button.setToolTip(self._tr("history_button_tooltip_text", "View download history")) + self.future_settings_button = QPushButton("⚙️") + self.future_settings_button.setFixedWidth(35) + self.future_settings_button.setStyleSheet("padding: 4px 6px;") + self.future_settings_button.clicked.connect(self._show_future_settings_dialog) + char_manage_layout.addWidget(self.add_to_filter_button, 1) + char_manage_layout.addWidget(self.delete_char_button, 1) + char_manage_layout.addWidget(self.known_names_help_button, 0) + char_manage_layout.addWidget(self.history_button, 0) + char_manage_layout.addWidget(self.future_settings_button, 0) + left_layout.addLayout(char_manage_layout) + left_layout.addStretch(0) - advanced_row1_layout .addWidget (self .use_cookie_checkbox ) - advanced_row1_layout .addWidget (self .cookie_text_input ,2 ) + # --- Right Panel (Logs) --- + # (This part of the layout is unchanged and remains correct) + log_title_layout = QHBoxLayout() + self.progress_log_label = QLabel("📜 Progress Log:") + log_title_layout.addWidget(self.progress_log_label) + log_title_layout.addStretch(1) + self.link_search_input = QLineEdit() + self.link_search_input.setPlaceholderText("Search Links...") + self.link_search_input.setVisible(False) + log_title_layout.addWidget(self.link_search_input) + self.link_search_button = QPushButton("🔍") + self.link_search_button.setVisible(False) + self.link_search_button.setFixedWidth(30) + self.link_search_button.setStyleSheet("padding: 4px 4px;") + log_title_layout.addWidget(self.link_search_button) + self.manga_rename_toggle_button = QPushButton() + self.manga_rename_toggle_button.setVisible(False) + self.manga_rename_toggle_button.setFixedWidth(140) + self.manga_rename_toggle_button.setStyleSheet("padding: 4px 8px;") + self._update_manga_filename_style_button_text() + log_title_layout.addWidget(self.manga_rename_toggle_button) + self.manga_date_prefix_input = QLineEdit() + self.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames") + self.manga_date_prefix_input.setVisible(False) + log_title_layout.addWidget(self.manga_date_prefix_input) + self.multipart_toggle_button = QPushButton() + self.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.") + self.multipart_toggle_button.setFixedWidth(130) + self.multipart_toggle_button.setStyleSheet("padding: 4px 8px;") + self._update_multipart_toggle_button_text() + log_title_layout.addWidget(self.multipart_toggle_button) + self.EYE_ICON = "\U0001F441" + self.CLOSED_EYE_ICON = "\U0001F648" + self.log_verbosity_toggle_button = QPushButton(self.EYE_ICON) + self.log_verbosity_toggle_button.setFixedWidth(45) + self.log_verbosity_toggle_button.setStyleSheet("font-size: 11pt; padding: 4px 2px;") + log_title_layout.addWidget(self.log_verbosity_toggle_button) + self.reset_button = QPushButton("🔄 Reset") + self.reset_button.setFixedWidth(80) + self.reset_button.setStyleSheet("padding: 4px 8px;") + log_title_layout.addWidget(self.reset_button) + right_layout.addLayout(log_title_layout) + self.log_splitter = QSplitter(Qt.Vertical) + self.log_view_stack = QStackedWidget() + self.main_log_output = QTextEdit() + self.main_log_output.setReadOnly(True) + self.main_log_output.setLineWrapMode(QTextEdit.NoWrap) + self.log_view_stack.addWidget(self.main_log_output) + self.missed_character_log_output = QTextEdit() + self.missed_character_log_output.setReadOnly(True) + self.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap) + self.log_view_stack.addWidget(self.missed_character_log_output) + self.external_log_output = QTextEdit() + self.external_log_output.setReadOnly(True) + self.external_log_output.setLineWrapMode(QTextEdit.NoWrap) + self.external_log_output.hide() + self.log_splitter.addWidget(self.log_view_stack) + self.log_splitter.addWidget(self.external_log_output) + self.log_splitter.setSizes([self.height(), 0]) + right_layout.addWidget(self.log_splitter, 1) + export_button_layout = QHBoxLayout() + export_button_layout.addStretch(1) + self.export_links_button = QPushButton(self._tr("export_links_button_text", "Export Links")) + self.export_links_button.setFixedWidth(100) + self.export_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") + self.export_links_button.setEnabled(False) + self.export_links_button.setVisible(False) + export_button_layout.addWidget(self.export_links_button) + self.download_extracted_links_button = QPushButton(self._tr("download_extracted_links_button_text", "Download")) + self.download_extracted_links_button.setFixedWidth(100) + self.download_extracted_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") + self.download_extracted_links_button.setEnabled(False) + self.download_extracted_links_button.setVisible(False) + export_button_layout.addWidget(self.download_extracted_links_button) + self.log_display_mode_toggle_button = QPushButton() + self.log_display_mode_toggle_button.setFixedWidth(120) + self.log_display_mode_toggle_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") + self.log_display_mode_toggle_button.setVisible(False) + export_button_layout.addWidget(self.log_display_mode_toggle_button) + right_layout.addLayout(export_button_layout) + 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("") + self.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.") + self.file_progress_label.setWordWrap(True) + self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;") + right_layout.addWidget(self.file_progress_label) - self .cookie_browse_button =QPushButton ("Browse...") - self .cookie_browse_button .setFixedWidth (80 ) - self .cookie_browse_button .setStyleSheet ("padding: 4px 8px;") - advanced_row1_layout .addWidget (self .cookie_browse_button ) + # --- Final Assembly --- + self.main_splitter.addWidget(left_scroll_area) # Use the scroll area + self.main_splitter.addWidget(right_panel_widget) + self.main_splitter.setStretchFactor(0, 7) + self.main_splitter.setStretchFactor(1, 3) + top_level_layout = QHBoxLayout(self) + top_level_layout.setContentsMargins(0, 0, 0, 0) + top_level_layout.addWidget(self.main_splitter) - advanced_row1_layout .addStretch (1 ) - checkboxes_group_layout .addLayout (advanced_row1_layout ) - - advanced_row2_layout =QHBoxLayout () - advanced_row2_layout .setSpacing (10 ) - - multithreading_layout =QHBoxLayout () - multithreading_layout .setContentsMargins (0 ,0 ,0 ,0 ) - self .use_multithreading_checkbox =QCheckBox ("Use Multithreading") - self .use_multithreading_checkbox .setChecked (True ) - 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 .setValidator (QIntValidator (1 ,MAX_THREADS )) - 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/Comic Mode") - 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 ) - - self .standard_action_buttons_widget =QWidget () - btn_layout =QHBoxLayout () - btn_layout .setContentsMargins (0 ,0 ,0 ,0 ) - btn_layout .setSpacing (10 ) - self .download_btn =QPushButton ("⬇️ Start Download") - self .download_btn .setStyleSheet ("padding: 4px 12px; font-weight: bold;") - self .download_btn .clicked .connect (self .start_download ) - - self .pause_btn =QPushButton ("⏸️ Pause Download") - self .pause_btn .setEnabled (False ) - self .pause_btn .setStyleSheet ("padding: 4px 12px;") - self .pause_btn .clicked .connect (self ._handle_pause_resume_action ) - - self .cancel_btn =QPushButton ("❌ Cancel & Reset UI") - - self .cancel_btn .setEnabled (False ) - self .cancel_btn .setStyleSheet ("padding: 4px 12px;") - self .cancel_btn .clicked .connect (self .cancel_download_button_action ) - - self .error_btn =QPushButton ("Error") - self .error_btn .setToolTip ("View error details (functionality TBD).") - self .error_btn .setStyleSheet ("padding: 4px 8px;") - self .error_btn .setEnabled (True ) - btn_layout .addWidget (self .download_btn ) - btn_layout .addWidget (self .pause_btn ) - btn_layout .addWidget (self .cancel_btn ) - btn_layout .addWidget (self .error_btn ) - self .standard_action_buttons_widget .setLayout (btn_layout ) - - self .bottom_action_buttons_stack =QStackedWidget () - self .bottom_action_buttons_stack .addWidget (self .standard_action_buttons_widget ) - self .bottom_action_buttons_stack .addWidget (self .favorite_action_buttons_widget ) - left_layout .addWidget (self .bottom_action_buttons_stack ) - left_layout .addSpacing (10 ) - - - known_chars_label_layout =QHBoxLayout () - known_chars_label_layout .setSpacing (10 ) - self .known_chars_label =QLabel ("🎭 Known Shows/Characters (for Folder Names):") - known_chars_label_layout .addWidget (self .known_chars_label ) - self .open_known_txt_button =QPushButton ("Open Known.txt") - self .open_known_txt_button .setStyleSheet ("padding: 4px 8px;") - self .open_known_txt_button .setFixedWidth (120 ) - known_chars_label_layout .addWidget (self .open_known_txt_button ) - self .character_search_input =QLineEdit () - self .character_search_input .setPlaceholderText ("Search characters...") - known_chars_label_layout .addWidget (self .character_search_input ,1 ) - left_layout .addLayout (known_chars_label_layout ) - - self .character_list =QListWidget () - self .character_list .setSelectionMode (QListWidget .ExtendedSelection ) - left_layout .addWidget (self .character_list ,1 ) - - char_manage_layout =QHBoxLayout () - char_manage_layout .setSpacing (10 ) - self .new_char_input =QLineEdit () - self .new_char_input .setPlaceholderText ("Add new show/character name") - self .new_char_input .setStyleSheet ("padding: 3px 5px;") - - self .add_char_button =QPushButton ("➕ Add") - self .add_char_button .setStyleSheet ("padding: 4px 10px;") - - self .add_to_filter_button =QPushButton ("⤵️ Add to Filter") - self .add_to_filter_button .setToolTip ("Select names from 'Known Shows/Characters' list to add to the 'Filter by Character(s)' field above.") - self .add_to_filter_button .setStyleSheet ("padding: 4px 10px;") - - self .delete_char_button =QPushButton ("🗑️ Delete Selected") - self .delete_char_button .setToolTip ("Delete the selected name(s) from the 'Known Shows/Characters' list.") - self .delete_char_button .setStyleSheet ("padding: 4px 10px;") - - self .add_char_button .clicked .connect (self ._handle_ui_add_new_character ) - self .new_char_input .returnPressed .connect (self .add_char_button .click ) - self .delete_char_button .clicked .connect (self .delete_selected_character ) - - char_manage_layout .addWidget (self .new_char_input ,2 ) - char_manage_layout .addWidget (self .add_char_button ,0 ) - - self .known_names_help_button =QPushButton ("?") - self .known_names_help_button .setFixedWidth (35 ) - self .known_names_help_button .setStyleSheet ("padding: 4px 6px;") - self .known_names_help_button .clicked .connect (self ._show_feature_guide ) - - self .history_button =QPushButton ("📜") - self .history_button .setFixedWidth (35 ) - self .history_button .setStyleSheet ("padding: 4px 6px;") - self .history_button .setToolTip (self ._tr ("history_button_tooltip_text","View download history")) - - self .future_settings_button =QPushButton ("⚙️") - self .future_settings_button .setFixedWidth (35 ) - self .future_settings_button .setStyleSheet ("padding: 4px 6px;") - self .future_settings_button .clicked .connect (self ._show_future_settings_dialog ) - char_manage_layout .addWidget (self .add_to_filter_button ,1 ) - char_manage_layout .addWidget (self .delete_char_button ,1 ) - char_manage_layout .addWidget (self .known_names_help_button ,0 ) - char_manage_layout .addWidget (self .history_button ,0 ) - char_manage_layout .addWidget (self .future_settings_button ,0 ) - left_layout .addLayout (char_manage_layout ) - left_layout .addStretch (0 ) - - log_title_layout =QHBoxLayout () - self .progress_log_label =QLabel ("📜 Progress Log:") - log_title_layout .addWidget (self .progress_log_label ) - log_title_layout .addStretch (1 ) - - self .link_search_input =QLineEdit () - self .link_search_input .setPlaceholderText ("Search Links...") - self .link_search_input .setVisible (False ) - - log_title_layout .addWidget (self .link_search_input ) - self .link_search_button =QPushButton ("🔍") - self .link_search_button .setVisible (False ) - self .link_search_button .setFixedWidth (30 ) - self .link_search_button .setStyleSheet ("padding: 4px 4px;") - log_title_layout .addWidget (self .link_search_button ) - - self .manga_rename_toggle_button =QPushButton () - self .manga_rename_toggle_button .setVisible (False ) - self .manga_rename_toggle_button .setFixedWidth (140 ) - self .manga_rename_toggle_button .setStyleSheet ("padding: 4px 8px;") - self ._update_manga_filename_style_button_text () - log_title_layout .addWidget (self .manga_rename_toggle_button ) - self .manga_date_prefix_input =QLineEdit () - self .manga_date_prefix_input .setPlaceholderText ("Prefix for Manga Filenames") - self .manga_date_prefix_input .setVisible (False ) - - log_title_layout .addWidget (self .manga_date_prefix_input ) - - self .multipart_toggle_button =QPushButton () - self .multipart_toggle_button .setToolTip ("Toggle between Multi-part and Single-stream downloads for large files.") - self .multipart_toggle_button .setFixedWidth (130 ) - self .multipart_toggle_button .setStyleSheet ("padding: 4px 8px;") - self ._update_multipart_toggle_button_text () - log_title_layout .addWidget (self .multipart_toggle_button ) - - self .EYE_ICON ="\U0001F441" - self .CLOSED_EYE_ICON ="\U0001F648" - self .log_verbosity_toggle_button =QPushButton (self .EYE_ICON ) - self .log_verbosity_toggle_button .setFixedWidth (45 ) - self .log_verbosity_toggle_button .setStyleSheet ("font-size: 11pt; padding: 4px 2px;") - log_title_layout .addWidget (self .log_verbosity_toggle_button ) - - self .reset_button =QPushButton ("🔄 Reset") - self .reset_button .setFixedWidth (80 ) - self .reset_button .setStyleSheet ("padding: 4px 8px;") - log_title_layout .addWidget (self .reset_button ) - right_layout .addLayout (log_title_layout ) - - self .log_splitter =QSplitter (Qt .Vertical ) - - self .log_view_stack =QStackedWidget () - - self .main_log_output =QTextEdit () - self .main_log_output .setReadOnly (True ) - self .main_log_output .setLineWrapMode (QTextEdit .NoWrap ) - self .log_view_stack .addWidget (self .main_log_output ) - - self .missed_character_log_output =QTextEdit () - self .missed_character_log_output .setReadOnly (True ) - self .missed_character_log_output .setLineWrapMode (QTextEdit .NoWrap ) - self .log_view_stack .addWidget (self .missed_character_log_output ) - - self .external_log_output =QTextEdit () - self .external_log_output .setReadOnly (True ) - self .external_log_output .setLineWrapMode (QTextEdit .NoWrap ) - self .external_log_output .hide () - - self .log_splitter .addWidget (self .log_view_stack ) - self .log_splitter .addWidget (self .external_log_output ) - self .log_splitter .setSizes ([self .height (),0 ]) - right_layout .addWidget (self .log_splitter ,1 ) - - export_button_layout =QHBoxLayout () - export_button_layout .addStretch (1 ) - self .export_links_button =QPushButton (self ._tr ("export_links_button_text","Export Links")) - self .export_links_button .setFixedWidth (100 ) - self .export_links_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;") - self .export_links_button .setEnabled (False ) - self .export_links_button .setVisible (False ) - export_button_layout .addWidget (self .export_links_button ) - - self .download_extracted_links_button =QPushButton (self ._tr ("download_extracted_links_button_text","Download")) - self .download_extracted_links_button .setFixedWidth (100 ) - self .download_extracted_links_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;") - self .download_extracted_links_button .setEnabled (False ) - self .download_extracted_links_button .setVisible (False ) - export_button_layout .addWidget (self .download_extracted_links_button ) - self .log_display_mode_toggle_button =QPushButton () - self .log_display_mode_toggle_button .setFixedWidth (120 ) - self .log_display_mode_toggle_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;") - self .log_display_mode_toggle_button .setVisible (False ) - export_button_layout .addWidget (self .log_display_mode_toggle_button ) - right_layout .addLayout (export_button_layout ) - - - 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 ("") - self .file_progress_label .setToolTip ("Shows the progress of individual file downloads, including speed and size.") - self .file_progress_label .setWordWrap (True ) - self .file_progress_label .setStyleSheet ("padding-top: 2px; font-style: italic; color: #A0A0A0;") - right_layout .addWidget (self .file_progress_label ) - - - self .main_splitter .addWidget (left_panel_widget ) - self .main_splitter .addWidget (right_panel_widget ) - - if self .width ()==0 or self .height ()==0 : - initial_width =1024 - else : - initial_width =self .width () - left_width =int (initial_width *0.35 ) - right_width =initial_width -left_width - self .main_splitter .setSizes ([left_width ,right_width ]) - - top_level_layout =QHBoxLayout (self ) - top_level_layout .setContentsMargins (0 ,0 ,0 ,0 ) - top_level_layout .addWidget (self .main_splitter ) - - self .update_ui_for_subfolders (self .use_subfolders_checkbox .isChecked ()) - 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 : - self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()) - if hasattr (self ,'link_input'):self .link_input .textChanged .connect (lambda :self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )) - - self ._load_creator_name_cache_from_json () - self .load_known_names_from_util () - self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ) - self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ()) - if hasattr (self ,'radio_group')and self .radio_group .checkedButton (): - self ._handle_filter_mode_change (self .radio_group .checkedButton (),True ) - self ._update_manga_filename_style_button_text () - self ._update_skip_scope_button_text () - self ._update_char_filter_scope_button_text () - self ._update_multithreading_for_date_mode () - if hasattr (self ,'download_thumbnails_checkbox'): - self ._handle_thumbnail_mode_change (self .download_thumbnails_checkbox .isChecked ()) - if hasattr (self ,'favorite_mode_checkbox'): - - self ._handle_favorite_mode_toggle (False ) + # --- Initial UI State Updates --- + self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked()) + 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: + self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) + if hasattr(self, 'link_input'): + self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)) + self._load_creator_name_cache_from_json() + self.load_known_names_from_util() + self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) + self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) + if hasattr(self, 'radio_group') and self.radio_group.checkedButton(): + self._handle_filter_mode_change(self.radio_group.checkedButton(), True) + self.radio_group.buttonToggled.connect(self._handle_more_options_toggled) # Add this line + + self._update_manga_filename_style_button_text() + self._update_skip_scope_button_text() + self._update_char_filter_scope_button_text() + self._update_multithreading_for_date_mode() + if hasattr(self, 'download_thumbnails_checkbox'): + self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked()) + if hasattr(self, 'favorite_mode_checkbox'): + self._handle_favorite_mode_toggle(False) def _load_persistent_history (self ): """Loads download history from a persistent file.""" @@ -1603,7 +1524,7 @@ class DownloaderApp (QWidget ): else : base_path_for_creators =self .app_base_dir - creators_file_path =os .path .join (base_path_for_creators ,"creators.json") + creators_file_path =os .path .join (base_path_for_creators ,"data" ,"creators.json") if not os .path .exists (creators_file_path ): self .log_signal .emit (f"⚠️ 'creators.json' not found at {creators_file_path }. Creator name cache will be empty.") @@ -1646,7 +1567,7 @@ class DownloaderApp (QWidget ): ) return - dialog =DownloadHistoryDialog (last_3_downloaded ,first_processed ,self ,self ) + dialog = DownloadHistoryDialog(last_3_downloaded, first_processed, self) dialog .exec_ () def _handle_actual_file_downloaded (self ,file_details_dict ): @@ -1729,7 +1650,7 @@ class DownloaderApp (QWidget ): QMessageBox .information (self ,"No Supported Links","No Mega, Google Drive, or Dropbox links were found in the extracted links.") return - dialog =DownloadExtractedLinksDialog (links_to_show_in_dialog ,self ,self ) + dialog = DownloadExtractedLinksDialog(links_to_show_in_dialog, self) dialog .download_requested .connect (self ._handle_extracted_links_download_request ) dialog .exec_ () @@ -1828,14 +1749,33 @@ class DownloaderApp (QWidget ): self .external_link_download_thread =None def _on_single_external_file_complete (self ,url ,success ): - - pass - def _show_future_settings_dialog (self ): + + + def _show_future_settings_dialog(self): """Shows the placeholder dialog for future settings.""" - dialog =FutureSettingsDialog (self ) - dialog =FutureSettingsDialog (self ,self ) - dialog .exec_ () + # --- DEBUGGING CODE TO FIND THE UNEXPECTED CALL --- + import traceback + print("--- DEBUG: _show_future_settings_dialog() was called. See stack trace below. ---") + traceback.print_stack() + print("--------------------------------------------------------------------------------") + + # Correctly create the dialog instance once with the parent set to self. + dialog = FutureSettingsDialog(self) + dialog.exec_() + + def _check_if_all_work_is_done(self): + """ + Checks if the fetcher thread is done AND if all submitted tasks have been processed. + If so, finalizes the download. + """ + # Conditions for being completely finished: + fetcher_is_done = not self.is_fetcher_thread_running + all_workers_are_done = (self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process) + + if fetcher_is_done and all_workers_are_done: + self.log_signal.emit("🏁 All fetcher and worker tasks complete.") + self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames) def _sync_queue_with_link_input (self ,current_text ): """ @@ -1932,7 +1872,8 @@ class DownloaderApp (QWidget ): 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; } - QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; min-height: 22px; } + /* --- FIX: Adjusted padding to match QLineEdit and removed min-height --- */ + QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 5px 12px; border-radius: 4px; } QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; } QPushButton:pressed { background-color: #4A4A4A; } QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; } @@ -1987,6 +1928,12 @@ class DownloaderApp (QWidget ): QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }") def handle_main_log (self ,message ): + # vvv ADD THIS BLOCK AT THE TOP OF THE METHOD vvv + if message.startswith("TEMP_FILE_PATH:"): + filepath = message.split(":", 1)[1] + if self.single_pdf_setting: + self.session_temp_files.append(filepath) + return is_html_message =message .startswith (HTML_PREFIX ) display_message =message use_html =False @@ -2219,6 +2166,36 @@ class DownloaderApp (QWidget ): elif not filename and not progress_info : self .file_progress_label .setText ("") + def _clear_stale_temp_files(self): + """On startup, cleans any temp files from a previous crashed session.""" + try: + temp_dir = os.path.join(self.app_base_dir, "appdata") + if not os.path.isdir(temp_dir): + return + + for filename in os.listdir(temp_dir): + if filename.startswith("tmp_") and filename.endswith(".json"): + try: + os.remove(os.path.join(temp_dir, filename)) + self.log_signal.emit(f" 🧹 Removed stale temp file: {filename}") + except OSError: + pass # File might be locked, skip + except Exception as e: + self.log_signal.emit(f"⚠️ Error cleaning stale temp files: {e}") + + def _cleanup_temp_files(self): + """Deletes all temporary files collected during the session.""" + if not self.session_temp_files: + return + + self.log_signal.emit(" Cleaning up temporary files...") + for filepath in self.session_temp_files: + try: + if os.path.exists(filepath): + os.remove(filepath) + except Exception as e: + self.log_signal.emit(f" ⚠️ Could not delete temp file '{filepath}': {e}") + self.session_temp_files = [] def update_external_links_setting (self ,checked ): is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () @@ -2249,9 +2226,15 @@ class DownloaderApp (QWidget ): self .log_signal .emit ("\n"+"="*40 +"\n🔗 External Links Log Disabled\n"+"="*40 ) - def _handle_filter_mode_change (self ,button ,checked ): - if not button or not checked : - return + def _handle_filter_mode_change(self, button, checked): + # Add this logic at the very beginning of the method + if button != self.radio_more and checked: + # If a button other than "More" is selected, reset its text and state + self.radio_more.setText("More") + self.more_filter_scope = None + + if not button or not checked: + return is_only_links =(button ==self .radio_only_links ) @@ -2478,7 +2461,9 @@ class DownloaderApp (QWidget ): def get_filter_mode (self ): - if self .radio_only_links and self .radio_only_links .isChecked (): + if self.radio_more and self.radio_more.isChecked(): + return 'text_only' + elif self.radio_only_links and self.radio_only_links.isChecked(): return 'all' elif self .radio_images .isChecked (): return 'image' @@ -2678,6 +2663,34 @@ class DownloaderApp (QWidget ): self .new_char_input .clear () return True + def _handle_more_options_toggled(self, button, checked): + """Shows the MoreOptionsDialog when the 'More' radio button is selected.""" + if button == self.radio_more and checked: + current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT + current_format = self.text_export_format or 'pdf' + + # Pass the current setting to the dialog + dialog = MoreOptionsDialog(self, current_scope=current_scope, current_format=current_format, single_pdf_checked=self.single_pdf_setting) + + if dialog.exec_() == QDialog.Accepted: + self.more_filter_scope = dialog.get_selected_scope() + self.text_export_format = dialog.get_selected_format() + self.single_pdf_setting = dialog.get_single_pdf_state() # Get the new setting + + scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description" + + # Update button text to show the selected format + format_display = f" ({self.text_export_format.upper()})" + if self.single_pdf_setting: + format_display = " (Single PDF)" + + self.radio_more.setText(f"{scope_text}{format_display}") + + self.log_signal.emit(f"ℹ️ 'More' filter scope set to: {scope_text}, Format: {self.text_export_format.upper()}") + self.log_signal.emit(f"ℹ️ Single PDF setting: {'Enabled' if self.single_pdf_setting else 'Disabled'}") + else: + self.log_signal.emit("ℹ️ 'More' filter selection cancelled. Reverting to 'All'.") + self.radio_all.setChecked(True) def delete_selected_character (self ): global KNOWN_NAMES @@ -3030,6 +3043,9 @@ class DownloaderApp (QWidget ): def start_download (self ,direct_api_url =None ,override_output_dir =None, is_restore=False ): global KNOWN_NAMES ,BackendDownloadThread ,PostProcessorWorker ,extract_post_info ,clean_folder_name ,MAX_FILE_THREADS_PER_POST_OR_WORKER + self._clear_stale_temp_files() + self.session_temp_files = [] + if self ._is_download_active (): QMessageBox.warning(self, "Busy", "A download is already in progress.") return False @@ -3145,7 +3161,7 @@ class DownloaderApp (QWidget ): lambda msg :self .log_signal .emit (f"[UI Cookie Check] {msg }") ) if temp_cookies_for_check is None : - cookie_dialog =CookieHelpDialog (self ,self ,offer_download_without_option =True ) + cookie_dialog = CookieHelpDialog(self, offer_download_without_option=True) dialog_exec_result =cookie_dialog .exec_ () if cookie_dialog .user_choice ==CookieHelpDialog .CHOICE_PROCEED_WITHOUT_COOKIES and dialog_exec_result ==QDialog .Accepted : @@ -3163,6 +3179,8 @@ class DownloaderApp (QWidget ): extract_links_only =(self .radio_only_links and self .radio_only_links .isChecked ()) backend_filter_mode =self .get_filter_mode () + text_only_scope_for_run = self.more_filter_scope if backend_filter_mode == 'text_only' else None + export_format_for_run = self.text_export_format if backend_filter_mode == 'text_only' else 'txt' checked_radio_button =self .radio_group .checkedButton () user_selected_filter_text =checked_radio_button .text ()if checked_radio_button else "All" @@ -3542,28 +3560,8 @@ class DownloaderApp (QWidget ): else : self .log_signal .emit (f"ℹ️ Using character filters for link extraction: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}") - - if manga_mode and not actual_filters_to_use_for_run and not extract_links_only : - msg_box =QMessageBox (self ) - msg_box .setIcon (QMessageBox .Warning ) - msg_box .setWindowTitle ("Manga Mode Filter Warning") - msg_box .setText ( - "Manga Mode is enabled, but 'Filter by Character(s)' is empty.\n\n" - "For best results (correct file naming and folder organization if subfolders are on), " - "please enter the Manga/Series title into the filter field.\n\n" - "Proceed without a filter (names might be generic, folder might be less specific)?" - ) - proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole ) - cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole ) - msg_box .exec_ () - if msg_box .clickedButton ()==cancel_button : - self .log_signal .emit ("❌ Download cancelled due to Manga Mode filter warning.") - return False - else : - self .log_signal .emit ("⚠️ Proceeding with Manga Mode without a specific title filter.") self .dynamic_character_filter_holder .set_filters (actual_filters_to_use_for_run ) - creator_folder_ignore_words_for_run =None character_filters_are_empty =not actual_filters_to_use_for_run if is_full_creator_download and character_filters_are_empty : @@ -3709,6 +3707,9 @@ class DownloaderApp (QWidget ): 'known_names_copy':list (KNOWN_NAMES ), 'filter_character_list':actual_filters_to_use_for_run , 'filter_mode':backend_filter_mode , + 'text_only_scope': text_only_scope_for_run, + 'text_export_format': export_format_for_run, + 'single_pdf_mode': self.single_pdf_setting, 'skip_zip':effective_skip_zip , 'skip_rar':effective_skip_rar , 'use_subfolders':use_subfolders , @@ -3746,12 +3747,14 @@ class DownloaderApp (QWidget ): 'selected_cookie_file':selected_cookie_file_path_for_backend , 'manga_global_file_counter_ref':manga_global_file_counter_ref_for_thread , 'app_base_dir':app_base_dir_for_cookies , + 'project_root_dir': self.app_base_dir, 'use_cookie':use_cookie_for_this_run , 'session_file_path': self.session_file_path, 'session_lock': self.session_lock, 'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run , 'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False, 'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False, + 'skip_current_file_flag': None, } args_template ['override_output_dir']=override_output_dir @@ -3776,7 +3779,9 @@ class DownloaderApp (QWidget ): 'manga_date_file_counter_ref', 'manga_global_file_counter_ref','manga_date_prefix', 'manga_mode_active','unwanted_keywords','manga_filename_style','scan_content_for_images', - 'allow_multipart_download','use_cookie','cookie_text','app_base_dir','selected_cookie_file','override_output_dir' + 'allow_multipart_download','use_cookie','cookie_text','app_base_dir','selected_cookie_file','override_output_dir','project_root_dir', + 'text_only_scope', + 'single_pdf_mode' ] args_template ['skip_current_file_flag']=None single_thread_args ={key :args_template [key ]for key in dt_expected_keys if key in args_template } @@ -3891,7 +3896,6 @@ class DownloaderApp (QWidget ): worker_instance =PostProcessorWorker (**worker_init_args ) if self .thread_pool : future =self .thread_pool .submit (worker_instance .process ) - future .add_done_callback (self ._handle_future_result ) self .active_futures .append (future ) return True else : @@ -3979,313 +3983,138 @@ class DownloaderApp (QWidget ): self .log_signal .emit (f"✅ Post fetcher thread started. {num_post_workers } post worker threads initializing...") self._update_button_states_and_connections() # Update buttons after fetcher thread starts - def _fetch_and_queue_posts (self ,api_url_input_for_fetcher ,worker_args_template ,num_post_workers ): - global PostProcessorWorker ,download_from_api - all_posts_data =[] - fetch_error_occurred =False - manga_mode_active_for_fetch =worker_args_template .get ('manga_mode_active',False ) - emitter_for_worker =worker_args_template .get ('emitter') - - is_restore = self.interrupted_session_data is not None - if is_restore: - all_posts_data = self.interrupted_session_data['download_state']['all_posts_data'] - processed_ids = set(self.interrupted_session_data['download_state']['processed_post_ids']) - posts_to_process = [p for p in all_posts_data if p.get('id') not in processed_ids] - self.log_signal.emit(f"Restoring session. {len(posts_to_process)} posts remaining out of {len(all_posts_data)}.") - self.total_posts_to_process = len(all_posts_data) - self.processed_posts_count = len(processed_ids) - self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) - - # Re-assign all_posts_data to only what needs processing - all_posts_data = posts_to_process - - if not emitter_for_worker : - self .log_signal .emit ("❌ CRITICAL ERROR: Emitter (queue) missing for worker in _fetch_and_queue_posts."); - self .finished_signal .emit (0 ,0 ,True ,[]); - return - + def _fetch_and_queue_posts(self, api_url_input_for_fetcher, worker_args_template, num_post_workers): + """ + Fetches post data and submits tasks to the pool. It does NOT wait for completion. + """ + global PostProcessorWorker, download_from_api + try: - self.log_signal.emit(" Fetching post data from API (this may take a moment for large feeds)...") - if not is_restore: # Only fetch new data if not restoring - post_generator = download_from_api( - api_url_input_for_fetcher, - logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), - 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, - pause_event=worker_args_template.get('pause_event'), - use_cookie=worker_args_template.get('use_cookie'), - cookie_text=worker_args_template.get('cookie_text'), - selected_cookie_file=worker_args_template.get('selected_cookie_file'), - app_base_dir=worker_args_template.get('app_base_dir'), - manga_filename_style_for_sort_check=( - worker_args_template.get('manga_filename_style') - if manga_mode_active_for_fetch - else None - ) - ) + # This section remains the same as before + post_generator = download_from_api( + api_url_input_for_fetcher, + logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), + start_page=worker_args_template.get('start_page'), + end_page=worker_args_template.get('end_page'), + manga_mode=worker_args_template.get('manga_mode_active', False), + cancellation_event=self.cancellation_event, + pause_event=self.pause_event, + use_cookie=worker_args_template.get('use_cookie'), + cookie_text=worker_args_template.get('cookie_text'), + selected_cookie_file=worker_args_template.get('selected_cookie_file'), + app_base_dir=worker_args_template.get('app_base_dir'), + manga_filename_style_for_sort_check=worker_args_template.get('manga_filename_style') + ) - for posts_batch in post_generator: - if self.cancellation_event.is_set(): - 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) - 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: - fetch_error_occurred = True; self.log_signal.emit(f"❌ API fetcher returned non-list type: {type(posts_batch)}"); break + 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','emitter', + 'pause_event','download_thumbnails','service','user_id','api_url_input', + 'cancellation_event','downloaded_files','downloaded_file_hashes','downloaded_files_lock', + 'downloaded_file_hashes_lock','remove_from_filename_words_list','dynamic_character_filter_holder', + 'skip_words_list','skip_words_scope','char_filter_scope','show_external_links', + 'extract_links_only','allow_multipart_download','use_cookie','cookie_text', + 'app_base_dir','selected_cookie_file','override_output_dir','num_file_threads', + 'skip_current_file_flag','manga_date_file_counter_ref','scan_content_for_images', + 'manga_mode_active','manga_filename_style','manga_date_prefix','text_only_scope', + 'text_export_format', 'single_pdf_mode', + 'use_date_prefix_for_subfolder','keep_in_post_duplicates','manga_global_file_counter_ref', + 'creator_download_folder_ignore_words','session_file_path','project_root_dir','session_lock' + ] + + num_file_dl_threads_for_each_worker = worker_args_template.get('num_file_threads_for_worker', 1) + emitter_for_worker = worker_args_template.get('emitter') - 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}") + for posts_batch in post_generator: + if self.cancellation_event.is_set(): + break + if isinstance(posts_batch, list) and posts_batch: + for post_data_item in posts_batch: + self._submit_post_to_worker_pool(post_data_item, worker_args_template, num_file_dl_threads_for_each_worker, emitter_for_worker, ppw_expected_keys, {}) + self.total_posts_to_process += len(posts_batch) + self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) + + except Exception as e: + self.log_signal.emit(f"❌ Error during post fetching: {e}\n{traceback.format_exc(limit=2)}") + finally: + # The fetcher's only job is to mark itself as done. + self.is_fetcher_thread_running = False + self.log_signal.emit("ℹ️ Post fetcher thread has finished submitting tasks.") - # Get a clean, serializable dictionary of UI settings - output_dir_for_session = worker_args_template.get('output_dir', self.dir_input.text().strip()) - ui_settings_for_session = self._get_current_ui_settings_as_dict( - api_url_override=api_url_input_for_fetcher, - output_dir_override=output_dir_for_session - ) + def _handle_worker_result(self, result_tuple: tuple): + """ + Safely processes results from a worker. This is now the ONLY place + that checks if the entire download process is complete. + """ + self.processed_posts_count += 1 + + try: + (downloaded, skipped, kept_originals, retryable, + permanent, history_data, + temp_filepath) = result_tuple - # Save initial session state - session_data = { - "timestamp": datetime.datetime.now().isoformat(), - "ui_settings": ui_settings_for_session, - "download_state": { - "all_posts_data": all_posts_data, - "processed_post_ids": [] - } - } - self._save_session_file(session_data) + if temp_filepath: self.session_temp_files.append(temp_filepath) + + with self.downloaded_files_lock: + self.download_counter += downloaded + self.skip_counter += skipped - # From here, all_posts_data is the list of posts to process (either new or restored) - unique_posts_dict ={} - for post in all_posts_data : - post_id =post .get ('id') - if post_id is not None : - if post_id not in unique_posts_dict : - unique_posts_dict [post_id ]=post - else : - self .log_signal .emit (f"⚠️ Skipping post with no ID: {post .get ('title','Untitled')}") + # Other result handling can go here if needed + if history_data: self._add_to_history_candidates(history_data) + if permanent: self.permanently_failed_files_for_dialog.extend(permanent) - posts_to_process_final = list(unique_posts_dict.values()) + self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) - if not is_restore: - self.total_posts_to_process = len(posts_to_process_final) - self.log_signal.emit(f" Processed {len(posts_to_process_final)} unique posts after de-duplication.") - if len(posts_to_process_final) < len(all_posts_data): - self.log_signal.emit(f" Note: {len(all_posts_data) - len(posts_to_process_final)} duplicate post IDs were removed.") - all_posts_data = posts_to_process_final + except Exception as e: + self.log_signal.emit(f"❌ Error in _handle_worker_result: {e}\n{traceback.format_exc(limit=2)}") + + # THE CRITICAL CHECK: + # Is the fetcher thread done AND have we processed all the tasks it submitted? + if not self.is_fetcher_thread_running and self.processed_posts_count >= self.total_posts_to_process: + self.log_signal.emit("🏁 All fetcher and worker tasks complete.") + self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames) - except TypeError as te : - self .log_signal .emit (f"❌ TypeError calling download_from_api: {te }\n Check 'downloader_utils.py' signature.\n{traceback .format_exc (limit =2 )}");fetch_error_occurred =True - except RuntimeError as re_err : - self .log_signal .emit (f"ℹ️ Post fetching runtime error (likely cancellation or API issue): {re_err }");fetch_error_occurred =True - except Exception as e : - self .log_signal .emit (f"❌ Error during post fetching: {e }\n{traceback .format_exc (limit =2 )}");fetch_error_occurred =True + def _trigger_single_pdf_creation(self): + """Reads temp files, sorts them by date, then creates the single PDF.""" + self.log_signal.emit("="*40) + self.log_signal.emit("Creating single PDF from collected text files...") - finally : - self .is_fetcher_thread_running =False - self .log_signal .emit (f"ℹ️ Post fetcher thread (_fetch_and_queue_posts) has completed its task. is_fetcher_thread_running set to False.") + posts_content_data = [] + for temp_filepath in self.session_temp_files: + try: + with open(temp_filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + posts_content_data.append(data) + except Exception as e: + self.log_signal.emit(f" ⚠️ Could not read temp file '{temp_filepath}': {e}") + + if not posts_content_data: + self.log_signal.emit(" No content was collected. Aborting PDF creation.") + return - 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 (),self .all_kept_original_filenames ) - if self .thread_pool :self .thread_pool .shutdown (wait =False ,cancel_futures =True );self .thread_pool =None - return + output_dir = self.dir_input.text().strip() or QStandardPaths.writableLocation(QStandardPaths.DownloadLocation) + default_filename = os.path.join(output_dir, "Consolidated_Content.pdf") + filepath, _ = QFileDialog.getSaveFileName(self, "Save Single PDF", default_filename, "PDF Files (*.pdf)") - if not all_posts_data: - self .log_signal .emit ("😕 No posts found or fetched to process.") - self .finished_signal .emit (0 ,0 ,False ,[]) - return + if not filepath: + self.log_signal.emit(" Single PDF creation cancelled by user.") + return - self .log_signal .emit (f" Preparing to submit {self .total_posts_to_process } post processing tasks to thread pool...") - if not is_restore: - self.processed_posts_count = 0 - self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) + if not filepath.lower().endswith('.pdf'): + filepath += '.pdf' + + font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + + # vvv THIS IS THE KEY CHANGE vvv + # Sort content by the 'published' date. ISO-formatted dates sort correctly as strings. + # Use a fallback value 'Z' to place any posts without a date at the end. + self.log_signal.emit(" Sorting collected posts by date (oldest first)...") + sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z')) + # ^^^ END OF KEY CHANGE ^^^ - num_file_dl_threads_for_each_worker =worker_args_template .get ('num_file_threads_for_worker',1 ) - - - 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', - 'emitter', - 'pause_event', - 'download_thumbnails', - 'service', - 'user_id', - 'api_url_input', - 'cancellation_event', - 'downloaded_files', - 'downloaded_file_hashes', - 'downloaded_files_lock', - 'downloaded_file_hashes_lock', - 'remove_from_filename_words_list', - 'dynamic_character_filter_holder', - 'skip_words_list', - 'skip_words_scope', - 'char_filter_scope', - 'show_external_links', - 'extract_links_only', - 'allow_multipart_download', - 'use_cookie', - 'cookie_text', - 'app_base_dir', - 'selected_cookie_file', - 'override_output_dir', - 'num_file_threads', - 'skip_current_file_flag', - 'manga_date_file_counter_ref', - 'scan_content_for_images', - 'manga_mode_active', - 'manga_filename_style', - 'manga_date_prefix', - 'use_date_prefix_for_subfolder', - 'keep_in_post_duplicates', - 'manga_global_file_counter_ref', - 'creator_download_folder_ignore_words', - 'session_file_path', - 'session_lock' - ] - - ppw_optional_keys_with_defaults ={ - 'skip_words_list','skip_words_scope','char_filter_scope','remove_from_filename_words_list', - 'show_external_links','extract_links_only','duplicate_file_mode', - 'num_file_threads','skip_current_file_flag','manga_mode_active','manga_filename_style','manga_date_prefix', - 'manga_date_file_counter_ref','use_cookie','cookie_text','app_base_dir','selected_cookie_file' - } - if num_post_workers >POST_WORKER_BATCH_THRESHOLD and self .total_posts_to_process >POST_WORKER_NUM_BATCHES : - self .log_signal .emit (f" High thread count ({num_post_workers }) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES } parts.") - - import math - tasks_submitted_in_batch_segment =0 - batch_size =math .ceil (self .total_posts_to_process /POST_WORKER_NUM_BATCHES ) - submitted_count_in_batching =0 - - for batch_num in range (POST_WORKER_NUM_BATCHES ): - if self .cancellation_event .is_set ():break - - if self .pause_event and self .pause_event .is_set (): - self .log_signal .emit (f" [Fetcher] Batch submission paused before batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }...") - while self .pause_event .is_set (): - if self .cancellation_event .is_set (): - self .log_signal .emit (" [Fetcher] Batch submission cancelled while paused.") - break - time .sleep (0.5 ) - if self .cancellation_event .is_set ():break - if not self .cancellation_event .is_set (): - self .log_signal .emit (f" [Fetcher] Batch submission resumed. Processing batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }.") - - start_index =batch_num *batch_size - end_index =min ((batch_num +1 )*batch_size ,self .total_posts_to_process ) - current_batch_posts =all_posts_data [start_index :end_index ] - - if not current_batch_posts :continue - - self .log_signal .emit (f" Submitting batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES } ({len (current_batch_posts )} posts) to pool...") - for post_data_item in current_batch_posts : - if self .cancellation_event .is_set ():break - success =self ._submit_post_to_worker_pool (post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ) - if success : - submitted_count_in_batching +=1 - tasks_submitted_in_batch_segment +=1 - if tasks_submitted_in_batch_segment %10 ==0 : - time .sleep (0.005 ) - tasks_submitted_in_batch_segment =0 - elif self .cancellation_event .is_set (): - break - - if self .cancellation_event .is_set ():break - - if batch_num 0 and self .processed_posts_count >=self .total_posts_to_process : - if all (f .done ()for f in self .active_futures ): - QApplication .processEvents () - 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 (),self .all_kept_original_filenames ) + create_single_pdf_from_content(sorted_content, filepath, font_path, logger=self.log_signal.emit) + self.log_signal.emit("="*40) def _add_to_history_candidates (self ,history_data ): """Adds processed post data to the history candidates list.""" @@ -4642,7 +4471,26 @@ class DownloaderApp (QWidget ): summary_log ="="*40 summary_log +=f"\n🏁 Download {status_message }!\n Summary: Downloaded Files={total_downloaded }, Skipped Files={total_skipped }\n" summary_log +="="*40 - self .log_signal .emit (summary_log ) + self.log_signal.emit (summary_log) + + # Safely shut down the thread pool now that all work is done. + if self.thread_pool: + self.log_signal.emit(" Shutting down worker thread pool...") + self.thread_pool.shutdown(wait=False) + self.thread_pool = None + self.log_signal.emit(" Thread pool shut down.") + + try: + if self.single_pdf_setting and self.session_temp_files and not cancelled_by_user: + self._trigger_single_pdf_creation() + finally: + # This ensures cleanup happens even if PDF creation fails or is cancelled + self._cleanup_temp_files() + self.single_pdf_setting = False + + # Reset session state for the next run + self.session_text_content = [] + self.single_pdf_setting = False if kept_original_names_list : intro_msg =( @@ -5306,7 +5154,7 @@ class DownloaderApp (QWidget ): QMessageBox .information (self ,"No Known Names","Your 'Known.txt' list is empty. Add some names first.") return - dialog =KnownNamesFilterDialog (KNOWN_NAMES ,self ,self ) + dialog = KnownNamesFilterDialog(KNOWN_NAMES, self) if dialog .exec_ ()==QDialog .Accepted : selected_entries =dialog .get_selected_entries () if selected_entries : @@ -5367,32 +5215,42 @@ class DownloaderApp (QWidget ): "Please 'Restore Download' or 'Discard Session' before selecting new creators.")) return - dialog =EmptyPopupDialog (self .app_base_dir ,self ,self ) - if dialog .exec_ ()==QDialog .Accepted : - if hasattr (dialog ,'selected_creators_for_queue')and dialog .selected_creators_for_queue : - self .favorite_download_queue .clear () + # Correctly create the dialog instance + dialog = EmptyPopupDialog(self.app_base_dir, self) + if dialog.exec_() == QDialog.Accepted: + if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: + self.favorite_download_queue.clear() - for creator_data in dialog .selected_creators_for_queue : - service =creator_data .get ('service') - creator_id =creator_data .get ('id') - creator_name =creator_data .get ('name','Unknown Creator') - domain =dialog ._get_domain_for_service (service ) + for creator_data in dialog.selected_creators_for_queue: + service = creator_data.get('service') + creator_id = creator_data.get('id') + creator_name = creator_data.get('name', 'Unknown Creator') + domain = dialog._get_domain_for_service(service) - if service and creator_id : - url =f"https://{domain }/{service }/user/{creator_id }" - queue_item ={ - 'url':url , - 'name':creator_name , - 'name_for_folder':creator_name , - 'type':'creator_popup_selection', - 'scope_from_popup':dialog .current_scope_mode + if service and creator_id: + url = f"https://{domain}/{service}/user/{creator_id}" + queue_item = { + 'url': url, + 'name': creator_name, + 'name_for_folder': creator_name, + 'type': 'creator_popup_selection', + 'scope_from_popup': dialog.current_scope_mode } - self .favorite_download_queue .append (queue_item ) + self.favorite_download_queue.append(queue_item) - if self .favorite_download_queue : - self .log_signal .emit (f"ℹ️ {len (self .favorite_download_queue )} creators added to download queue from popup. Click 'Start Download' to process.") - if hasattr (self ,'link_input'): - self .last_link_input_text_for_queue_sync =self .link_input .text () + if self.favorite_download_queue: + # --- NEW: This block adds the selected creator names to the input field --- + if hasattr(self, 'link_input'): + # 1. Get all the names from the queue + creator_names = [item['name'] for item in self.favorite_download_queue] + # 2. Join them into a single string + display_text = ", ".join(creator_names) + # 3. Set the text of the URL input field + self.link_input.setText(display_text) + + self.log_signal.emit(f"ℹ️ {len(self.favorite_download_queue)} creators added to download queue from popup. Click 'Start Download' to process.") + if hasattr(self, 'link_input'): + self.last_link_input_text_for_queue_sync = self.link_input.text() def _show_favorite_artists_dialog (self ): if self ._is_download_active ()or self .is_processing_favorites_queue : @@ -5516,6 +5374,32 @@ class DownloaderApp (QWidget ): self .log_signal .emit ("ℹ️ Favorite posts selection cancelled.") def _process_next_favorite_download (self ): + + if self.favorite_download_queue and not self.is_processing_favorites_queue: + manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False + char_filter_is_empty = not self.character_input.text().strip() + extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked()) + + if manga_mode_is_checked and char_filter_is_empty and not extract_links_only: + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Manga Mode Filter Warning") + msg_box.setText( + "Manga Mode is enabled, but 'Filter by Character(s)' is empty.\n\n" + "This is a one-time warning for this entire batch of downloads.\n\n" + "Proceeding without a filter may result in generic filenames and folders.\n\n" + "Proceed with the entire batch?" + ) + proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) + cancel_button = msg_box.addButton("Cancel Entire Batch", QMessageBox.RejectRole) + msg_box.exec_() + if msg_box.clickedButton() == cancel_button: + self.log_signal.emit("❌ Entire favorite queue cancelled by user at Manga Mode warning.") + self.favorite_download_queue.clear() + self.is_processing_favorites_queue = False + self.set_ui_enabled(True) + return # Stop processing the queue + if self ._is_download_active (): self .log_signal .emit ("ℹ️ Waiting for current download to finish before starting next favorite.") return