diff --git a/src/config/constants.py b/src/config/constants.py index 2b2cfe7..d4e888d 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -59,6 +59,7 @@ LANGUAGE_KEY = "currentLanguageV1" DOWNLOAD_LOCATION_KEY = "downloadLocationV1" RESOLUTION_KEY = "window_resolution" UI_SCALE_KEY = "ui_scale_factor" +SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile" # --- UI Constants and Identifiers --- HTML_PREFIX = "" diff --git a/src/core/manager.py b/src/core/manager.py index a4f02cb..32093c6 100644 --- a/src/core/manager.py +++ b/src/core/manager.py @@ -41,6 +41,9 @@ class DownloadManager: self.total_downloads = 0 self.total_skips = 0 self.all_kept_original_filenames = [] + self.creator_profiles_dir = None + self.current_creator_name_for_profile = None + self.current_creator_profile_path = None def _log(self, message): """Puts a progress message into the queue for the UI.""" @@ -58,6 +61,13 @@ class DownloadManager: if self.is_running: self._log("❌ Cannot start a new session: A session is already in progress.") return + + creator_profile_data = self._setup_creator_profile(config) + creator_profile_data['settings'] = config + creator_profile_data.setdefault('processed_post_ids', []) + self._save_creator_profile(creator_profile_data) + self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.") + self.is_running = True self.cancellation_event.clear() self.pause_event.clear() @@ -72,11 +82,11 @@ class DownloadManager: is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential - + if should_use_multithreading_for_posts: fetcher_thread = threading.Thread( target=self._fetch_and_queue_posts_for_pool, - args=(config, restore_data), + args=(config, restore_data, creator_profile_data), # Add argument here daemon=True ) fetcher_thread.start() @@ -112,6 +122,11 @@ class DownloadManager: try: num_workers = min(config.get('num_threads', 4), MAX_THREADS) self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') + + session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set() + profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) + processed_ids = session_processed_ids.union(profile_processed_ids) + if restore_data: all_posts = restore_data['all_posts_data'] processed_ids = set(restore_data['processed_post_ids']) @@ -196,12 +211,52 @@ class DownloadManager: self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)}) if history: self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)}) + post_id = history.get('post_id') + if post_id and self.current_creator_profile_path: + profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path}) + if post_id not in profile_data.get('processed_post_ids', []): + profile_data.setdefault('processed_post_ids', []).append(post_id) + self._save_creator_profile(profile_data) except Exception as e: self._log(f"❌ Worker task resulted in an exception: {e}") self.total_skips += 1 # Count errored posts as skipped self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) + def _setup_creator_profile(self, config): + """Prepares the path and loads data for the current creator's profile.""" + self.current_creator_name_for_profile = config.get('creator_name_for_profile') + if not self.current_creator_name_for_profile: + self._log("⚠️ Cannot create creator profile: Name not provided in config.") + return {} + + appdata_dir = os.path.dirname(config.get('session_file_path', '.')) + self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles") + os.makedirs(self.creator_profiles_dir, exist_ok=True) + + safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json" + self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename) + + if os.path.exists(self.current_creator_profile_path): + try: + with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.") + return {} + + def _save_creator_profile(self, data): + """Saves the provided data to the current creator's profile file.""" + if not self.current_creator_profile_path: + return + try: + temp_path = self.current_creator_profile_path + ".tmp" + with open(temp_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + os.replace(temp_path, self.current_creator_profile_path) + except OSError as e: + self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}") + def cancel_session(self): """Cancels the current running session.""" if not self.is_running: diff --git a/src/core/workers.py b/src/core/workers.py index 1e2d182..6ba8cd1 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -238,13 +238,24 @@ class PostProcessorWorker: if self.manga_mode_active: if self.manga_filename_style == STYLE_ORIGINAL_NAME: - filename_to_save_in_main_path = cleaned_original_api_filename - if self.manga_date_prefix and self.manga_date_prefix.strip(): - cleaned_prefix = clean_filename(self.manga_date_prefix.strip()) - if cleaned_prefix: - filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}" - else: - self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.") + # Get the post's publication or added date + published_date_str = self.post.get('published') + added_date_str = self.post.get('added') + formatted_date_str = "nodate" # Fallback if no date is found + + date_to_use_str = published_date_str or added_date_str + + if date_to_use_str: + try: + # Extract just the YYYY-MM-DD part from the timestamp + formatted_date_str = date_to_use_str.split('T')[0] + except Exception: + self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.") + else: + self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.") + + # Combine the date with the cleaned original filename + filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}" was_original_name_kept_flag = True elif self.manga_filename_style == STYLE_POST_TITLE: if post_title and post_title.strip(): @@ -1385,7 +1396,17 @@ class PostProcessorWorker: if not all_files_from_post_api: self.logger(f" No files found to download for post {post_id}.") - result_tuple = (0, 0, [], [], [], None, None) + history_data_for_no_files_post = { + 'post_title': post_title, + 'post_id': post_id, + 'service': self.service, + 'user_id': self.user_id, + 'top_file_name': "N/A (No Files)", + 'num_files': 0, + 'upload_date_str': post_data.get('published') or post_data.get('added') or "Unknown", + 'download_location': determined_post_save_path_for_history + } + result_tuple = (0, 0, [], [], [], history_data_for_no_files_post, None) return result_tuple files_to_download_info_list = [] diff --git a/src/services/drive_downloader.py b/src/services/drive_downloader.py index a4f8bdb..4d01a09 100644 --- a/src/services/drive_downloader.py +++ b/src/services/drive_downloader.py @@ -15,9 +15,9 @@ except ImportError: try: import gdown - GDOWN_AVAILABLE = True + GDRIVE_AVAILABLE = True except ImportError: - GDOWN_AVAILABLE = False + GDRIVE_AVAILABLE = False # --- Helper Functions --- @@ -46,75 +46,76 @@ def _get_filename_from_headers(headers): # --- Main Service Downloader Functions --- -def download_mega_file(mega_link, download_path=".", logger_func=print): +def download_mega_file(mega_url, download_path, logger_func=print): """ - Downloads a file from a public Mega.nz link. - - Args: - mega_link (str): The public Mega.nz link to the file. - download_path (str): The directory to save the downloaded file. - logger_func (callable): Function to use for logging. + Downloads a file from a Mega.nz URL. + Handles both public links and links that include a decryption key. """ if not MEGA_AVAILABLE: - logger_func("❌ Error: mega.py library is not installed. Cannot download from Mega.") - logger_func(" Please install it: pip install mega.py") - raise ImportError("mega.py library not found.") + logger_func("❌ Mega download failed: 'mega.py' library is not installed.") + return logger_func(f" [Mega] Initializing Mega client...") try: - mega_client = Mega() - m = mega_client.login() - logger_func(f" [Mega] Attempting to download from: {mega_link}") - - if not os.path.exists(download_path): - os.makedirs(download_path, exist_ok=True) - logger_func(f" [Mega] Created download directory: {download_path}") - - # The download_url method handles file info fetching and saving internally. - downloaded_file_path = m.download_url(mega_link, dest_path=download_path) + mega = Mega() + # Anonymous login is sufficient for public links + m = mega.login() - if downloaded_file_path and os.path.exists(downloaded_file_path): - logger_func(f" [Mega] ✅ File downloaded successfully! Saved as: {downloaded_file_path}") - else: - raise Exception(f"Mega download failed or file not found. Returned: {downloaded_file_path}") + # --- MODIFIED PART: Added error handling for invalid links --- + try: + file_details = m.find(mega_url) + if file_details is None: + logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}") + return + except (ValueError, json.JSONDecodeError) as e: + # This block catches the "Expecting value" error + logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}") + return + except Exception as e: + # Catch other potential errors from the mega.py library + logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}") + return + # --- END OF MODIFIED PART --- + + filename = file_details[1]['a']['n'] + logger_func(f" [Mega] File found: '{filename}'. Starting download...") + + # Sanitize filename before saving + safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip() + final_path = os.path.join(download_path, safe_filename) + + # Check if file already exists + if os.path.exists(final_path): + logger_func(f" [Mega] ℹ️ File '{safe_filename}' already exists. Skipping download.") + return + + # Start the download + m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename) + logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'") except Exception as e: - logger_func(f" [Mega] ❌ An unexpected error occurred during Mega download: {e}") - traceback.print_exc(limit=2) - raise # Re-raise the exception to be handled by the calling worker + logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}") -def download_gdrive_file(gdrive_link, download_path=".", logger_func=print): - """ - Downloads a file from a public Google Drive link using the gdown library. - - Args: - gdrive_link (str): The public Google Drive link to the file. - download_path (str): The directory to save the downloaded file. - logger_func (callable): Function to use for logging. - """ - if not GDOWN_AVAILABLE: - logger_func("❌ Error: gdown library is not installed. Cannot download from Google Drive.") - logger_func(" Please install it: pip install gdown") - raise ImportError("gdown library not found.") - - logger_func(f" [GDrive] Attempting to download: {gdrive_link}") +def download_gdrive_file(url, download_path, logger_func=print): + """Downloads a file from a Google Drive link.""" + if not GDRIVE_AVAILABLE: + logger_func("❌ Google Drive download failed: 'gdown' library is not installed.") + return try: - if not os.path.exists(download_path): - os.makedirs(download_path, exist_ok=True) - logger_func(f" [GDrive] Created download directory: {download_path}") - - # gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats. - output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True) - - if output_file_path and os.path.exists(output_file_path): - logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}") + logger_func(f" [G-Drive] Starting download for: {url}") + # --- MODIFIED PART: Added a message and set quiet=True --- + logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.") + + # By setting quiet=True, the progress bar will no longer be printed to the terminal. + output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True) + # --- END OF MODIFIED PART --- + + if output_path and os.path.exists(output_path): + logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'") else: - raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}") - + logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.") except Exception as e: - logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}") - traceback.print_exc(limit=2) - raise + logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}") def download_dropbox_file(dropbox_link, download_path=".", logger_func=print): """ diff --git a/src/ui/dialogs/EmptyPopupDialog.py b/src/ui/dialogs/EmptyPopupDialog.py index a505dad..422d1b6 100644 --- a/src/ui/dialogs/EmptyPopupDialog.py +++ b/src/ui/dialogs/EmptyPopupDialog.py @@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, - QSplitter, QProgressBar, QWidget + QSplitter, QProgressBar, QWidget, QFileDialog ) # --- Local Application Imports --- @@ -151,6 +151,8 @@ class EmptyPopupDialog (QDialog ): app_icon =get_app_icon_object () if app_icon and not app_icon .isNull (): self .setWindowIcon (app_icon ) + self.update_profile_data = None + self.update_creator_name = None self .selected_creators_for_queue =[] self .globally_selected_creators ={} self .fetched_posts_data ={} @@ -205,6 +207,9 @@ class EmptyPopupDialog (QDialog ): self .scope_button .clicked .connect (self ._toggle_scope_mode ) left_bottom_buttons_layout .addWidget (self .scope_button ) left_pane_layout .addLayout (left_bottom_buttons_layout ) + self.update_button = QPushButton() + self.update_button.clicked.connect(self._handle_update_check) + left_bottom_buttons_layout.addWidget(self.update_button) self .right_pane_widget =QWidget () @@ -315,6 +320,31 @@ class EmptyPopupDialog (QDialog ): except AttributeError : pass + def _handle_update_check(self): + """Opens a dialog to select a creator profile and loads it for an update session.""" + appdata_dir = os.path.join(self.app_base_dir, "appdata") + profiles_dir = os.path.join(appdata_dir, "creator_profiles") + + if not os.path.isdir(profiles_dir): + QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}") + return + + filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)") + + if filepath: + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + if 'creator_url' not in data or 'processed_post_ids' not in data: + raise ValueError("Invalid profile format.") + + self.update_profile_data = data + self.update_creator_name = os.path.basename(filepath).replace('.json', '') + self.accept() # Close the dialog and signal success + except Exception as e: + QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}") + def _handle_fetch_posts_click (self ): selected_creators =list (self .globally_selected_creators .values ()) print(f"[DEBUG] Selected creators for fetch: {selected_creators}") @@ -370,6 +400,7 @@ class EmptyPopupDialog (QDialog ): self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected")) self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts")) self ._update_scope_button_text_and_tooltip () + self.update_button.setText(self._tr("check_for_updates_button", "Check for Updates")) self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title...")) diff --git a/src/ui/dialogs/FutureSettingsDialog.py b/src/ui/dialogs/FutureSettingsDialog.py index 5102b8e..44069e0 100644 --- a/src/ui/dialogs/FutureSettingsDialog.py +++ b/src/ui/dialogs/FutureSettingsDialog.py @@ -6,7 +6,7 @@ import json from PyQt5.QtCore import Qt, QStandardPaths from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, - QGroupBox, QComboBox, QMessageBox, QGridLayout + QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox ) # --- Local Application Imports --- @@ -15,7 +15,7 @@ from ...utils.resolution import get_dark_theme from ..main_window import get_app_icon_object from ...config.constants import ( THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY, - RESOLUTION_KEY, UI_SCALE_KEY + RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY ) @@ -35,7 +35,7 @@ class FutureSettingsDialog(QDialog): screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800 scale_factor = screen_height / 800.0 - base_min_w, base_min_h = 420, 320 # Adjusted height for new layout + base_min_w, base_min_h = 420, 360 # Adjusted height for new layout scaled_min_w = int(base_min_w * scale_factor) scaled_min_h = int(base_min_h * scale_factor) self.setMinimumSize(scaled_min_w, scaled_min_h) @@ -93,6 +93,11 @@ class FutureSettingsDialog(QDialog): download_window_layout.addWidget(self.default_path_label, 1, 0) download_window_layout.addWidget(self.save_path_button, 1, 1) + # Save Creator.json Checkbox + self.save_creator_json_checkbox = QCheckBox() + self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed) + download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2) + main_layout.addWidget(self.download_window_group_box) main_layout.addStretch(1) @@ -102,6 +107,20 @@ class FutureSettingsDialog(QDialog): self.ok_button.clicked.connect(self.accept) main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom) + def _load_checkbox_states(self): + """Loads the initial state for all checkboxes from settings.""" + self.save_creator_json_checkbox.blockSignals(True) + # Default to True so the feature is on by default for users + should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) + self.save_creator_json_checkbox.setChecked(should_save) + self.save_creator_json_checkbox.blockSignals(False) + + def _creator_json_setting_changed(self, state): + """Saves the state of the 'Save Creator.json' checkbox.""" + is_checked = state == Qt.Checked + self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked) + self.parent_app.settings.sync() + def _tr(self, key, default_text=""): if callable(get_translation) and self.parent_app: return get_translation(self.parent_app.current_selected_language, key, default_text) @@ -122,6 +141,7 @@ class FutureSettingsDialog(QDialog): # Download & Window Group Labels self.window_size_label.setText(self._tr("window_size_label", "Window Size:")) self.default_path_label.setText(self._tr("default_path_label", "Default Path:")) + self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file")) # Buttons and Controls self._update_theme_toggle_button_text() @@ -132,6 +152,7 @@ class FutureSettingsDialog(QDialog): # Populate dropdowns self._populate_display_combo_boxes() self._populate_language_combo_box() + self._load_checkbox_states() def _apply_theme(self): if self.parent_app and self.parent_app.current_theme == "dark": diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 9d766de..4161532 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -97,6 +97,8 @@ class DownloaderApp (QWidget ): def __init__(self): super().__init__() self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN) + self.active_update_profile = None + self.new_posts_for_update = [] self.is_finishing = False saved_res = self.settings.value(RESOLUTION_KEY, "Auto") @@ -113,9 +115,13 @@ class DownloaderApp (QWidget ): else: self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) - 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") + executable_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else self.app_base_dir + user_data_path = os.path.join(executable_dir, "appdata") + os.makedirs(user_data_path, exist_ok=True) + + self.config_file = os.path.join(user_data_path, "Known.txt") + self.session_file_path = os.path.join(user_data_path, "session.json") + self.persistent_history_file = os.path.join(user_data_path, "download_history.json") self.download_thread = None self.thread_pool = None @@ -222,6 +228,7 @@ class DownloaderApp (QWidget ): self.downloaded_hash_counts = defaultdict(int) self.downloaded_hash_counts_lock = threading.Lock() self.session_temp_files = [] + self.save_creator_json_enabled_this_session = True print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}") @@ -253,7 +260,7 @@ class DownloaderApp (QWidget ): self.download_location_label_widget = None self.remove_from_filename_label_widget = None self.skip_words_label_widget = None - self.setWindowTitle("Kemono Downloader v6.1.0") + self.setWindowTitle("Kemono Downloader v6.2.0") setup_ui(self) self._connect_signals() self.log_signal.emit("ℹ️ Local API server functionality has been removed.") @@ -294,6 +301,45 @@ class DownloaderApp (QWidget ): if msg_box.clickedButton() == restart_button: self._request_restart_application() + def _setup_creator_profile(self, creator_name, session_file_path): + """Prepares the path and loads data for the current creator's profile.""" + if not creator_name: + self.log_signal.emit("⚠️ Cannot create creator profile: Name not provided.") + return {} + + appdata_dir = os.path.dirname(session_file_path) + creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles") + os.makedirs(creator_profiles_dir, exist_ok=True) + + safe_filename = clean_folder_name(creator_name) + ".json" + profile_path = os.path.join(creator_profiles_dir, safe_filename) + + if os.path.exists(profile_path): + try: + with open(profile_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + self.log_signal.emit(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.") + return {} + + def _save_creator_profile(self, creator_name, data, session_file_path): + """Saves the provided data to the current creator's profile file.""" + if not creator_name: + return + + appdata_dir = os.path.dirname(session_file_path) + creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles") + safe_filename = clean_folder_name(creator_name) + ".json" + profile_path = os.path.join(creator_profiles_dir, safe_filename) + + try: + temp_path = profile_path + ".tmp" + with open(temp_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + os.replace(temp_path, profile_path) + except OSError as e: + self.log_signal.emit(f"❌ Error saving creator profile to '{profile_path}': {e}") + def _create_initial_session_file(self, api_url_for_session, override_output_dir_for_session, remaining_queue=None): """Creates the initial session file at the start of a new download.""" if self.is_restore_pending: @@ -478,7 +524,7 @@ class DownloaderApp (QWidget ): 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). + based on the current application state. """ try: self.download_btn.clicked.disconnect() except TypeError: pass @@ -489,7 +535,38 @@ class DownloaderApp (QWidget ): is_download_active = self._is_download_active() - if self.is_restore_pending: + if self.active_update_profile and self.new_posts_for_update and not is_download_active: + # State: Update confirmation (new posts found, waiting for user to start) + num_new = len(self.new_posts_for_update) + self.download_btn.setText(self._tr("start_download_new_button_text", f"⬇️ Start Download ({num_new} new)")) + self.download_btn.setEnabled(True) + self.download_btn.clicked.connect(self.start_download) + self.download_btn.setToolTip(self._tr("start_download_new_tooltip", "Click to download the new posts found.")) + + self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download")) + self.pause_btn.setEnabled(False) + + self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection")) + self.cancel_btn.setEnabled(True) + self.cancel_btn.clicked.connect(self._clear_update_selection) + self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to cancel the update and clear the selection.")) + + elif self.active_update_profile and not is_download_active: + # State: Update check (profile loaded, waiting for user to check) + self.download_btn.setText(self._tr("check_for_updates_button_text", "🔄 Check For Updates")) + self.download_btn.setEnabled(True) + self.download_btn.clicked.connect(self.start_download) + self.download_btn.setToolTip(self._tr("check_for_updates_tooltip", "Click to check for new posts from this creator.")) + + self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download")) + self.pause_btn.setEnabled(False) + + self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection")) + self.cancel_btn.setEnabled(True) + self.cancel_btn.clicked.connect(self._clear_update_selection) + self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to clear the loaded creator profile and return to normal mode.")) + + elif self.is_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) @@ -507,7 +584,7 @@ class DownloaderApp (QWidget ): elif is_download_active: 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.download_btn.setEnabled(False) 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) @@ -524,13 +601,19 @@ class DownloaderApp (QWidget ): 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.setEnabled(False) 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.setEnabled(False) 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 _clear_update_selection(self): + """Clears the loaded creator profile and fully resets the UI to its default state.""" + self.log_signal.emit("ℹ️ Update selection cleared. Resetting UI to defaults.") + self.active_update_profile = None + self.new_posts_for_update = [] + self._reset_ui_to_defaults() def _retranslate_main_ui (self ): """Retranslates static text elements in the main UI.""" @@ -1618,42 +1701,75 @@ class DownloaderApp (QWidget ): def _display_and_schedule_next (self ,link_data ): - post_title ,link_text ,link_url ,platform ,decryption_key =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 () - if is_only_links_mode: + # --- FONT STYLE DEFINITION --- + # Use a clean, widely available sans-serif font family for a modern look. + font_style = "font-family: 'Segoe UI', Helvetica, Arial, sans-serif;" + + if is_only_links_mode : if post_title != self._current_link_post_title: if self._current_link_post_title is not None: separator_html = f'{HTML_PREFIX}
' self.log_signal.emit(separator_html) - title_html = f'{HTML_PREFIX}

{html.escape(post_title)}

' - self.log_signal.emit(title_html) + + # Apply font style to the title + title_html = f'

{html.escape(post_title)}

' + self.log_signal.emit(HTML_PREFIX + title_html) self._current_link_post_title = post_title - display_text = html.escape(link_text.strip() if link_text.strip() else link_url) - link_html_parts = [ - f'
' - f'• {display_text}' - f' ({html.escape(platform)})' - ] + + # Use the "smarter" logic to decide what text to show for the link + cleaned_link_text = link_text.strip() + display_text = "" + if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url: + display_text = cleaned_link_text + else: + try: + path = urlparse(link_url).path + filename = os.path.basename(path) + if filename: + display_text = filename + except Exception: + pass + if not display_text: + display_text = link_url + + # Truncate long display text + if len(display_text) > 50: + display_text = display_text[:50].strip() + "..." + + # Format the output as requested + platform_display = platform.capitalize() + + # Escape parts that will be displayed as text + escaped_url = html.escape(link_url) + escaped_display_text = html.escape(f"({display_text})") + + # Apply font style to the link information and wrap in a paragraph tag + link_html_line = ( + f'

' + f" {platform_display} - {escaped_url} - {escaped_display_text}" + ) if decryption_key: - link_html_parts.append( - f'
' - f'Key: {html.escape(decryption_key)}' - ) + escaped_key = html.escape(f"(Decryption Key: {decryption_key})") + link_html_line += f" {escaped_key}" - link_html_parts.append('

') - - final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}' - self.log_signal.emit(final_link_html) + link_html_line += '

' + + # Emit the entire line as a single HTML signal + self.log_signal.emit(HTML_PREFIX + link_html_line) + elif self .show_external_links : - separator ="-"*45 + # This part for the secondary log remains unchanged + separator ="-"*45 formatted_link_info = f"{link_text} - {link_url} - {platform}" if decryption_key: formatted_link_info += f" (Decryption Key: {decryption_key})" self._append_to_external_log(formatted_link_info, separator) - self ._is_processing_external_link_queue =False + self ._is_processing_external_link_queue =False self ._try_process_next_external_link () @@ -1912,70 +2028,97 @@ class DownloaderApp (QWidget ): def _filter_links_log (self ): - if not (self .radio_only_links and self .radio_only_links .isChecked ()):return + 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 "" + # This block handles the "Download Progress" view for Mega/Drive links and should be kept 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).") + self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log.") + return 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.") + return - else : + # Simplified logic: Clear the log and re-trigger the display process + # The main display logic is now fully handled by _display_and_schedule_next + if self .main_log_output :self .main_log_output .clear () + self._current_link_post_title = None # Reset the title tracking for the new display pass - 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)") + # Create a new temporary queue containing only the links that match the search term + filtered_link_queue = deque() + 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 matches_search : + filtered_link_queue.append((post_title, link_text, link_url, platform, decryption_key)) + if not filtered_link_queue: + self .log_signal .emit (" (No links extracted yet or all filtered out in links view)") + else: + self.external_link_queue.clear() + self.external_link_queue.extend(filtered_link_queue) + self._try_process_next_external_link() if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ()) + 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 () + + if is_only_links_mode : + if post_title != self._current_link_post_title: + if self._current_link_post_title is not None: + separator_html = f'{HTML_PREFIX}
' + self.log_signal.emit(separator_html) + + title_html = f'

{html.escape(post_title)}

' + self.log_signal.emit(HTML_PREFIX + title_html) + self._current_link_post_title = post_title + + # Use the "smarter" logic to decide what text to show for the link + cleaned_link_text = link_text.strip() + display_text = "" + if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url: + display_text = cleaned_link_text + else: + try: + path = urlparse(link_url).path + filename = os.path.basename(path) + if filename: + display_text = filename + except Exception: + pass + if not display_text: + display_text = link_url + + # Truncate long display text + if len(display_text) > 50: + display_text = display_text[:50].strip() + "..." + + # --- NEW: Format the output as requested --- + platform_display = platform.capitalize() + plain_link_info_line = f" {platform_display} - {link_url} - ({display_text})" + if decryption_key: + plain_link_info_line += f" (Decryption Key: {decryption_key})" + + self.main_log_output.append(plain_link_info_line) + + elif self .show_external_links : + # This part for the secondary log remains unchanged + separator ="-"*45 + formatted_link_info = f"{link_text} - {link_url} - {platform}" + if decryption_key: + formatted_link_info += f" (Decryption Key: {decryption_key})" + self._append_to_external_log(formatted_link_info, separator) + + self ._is_processing_external_link_queue =False + self ._try_process_next_external_link () def _export_links_to_file (self ): if not (self .radio_only_links and self .radio_only_links .isChecked ()): @@ -2295,7 +2438,10 @@ class DownloaderApp (QWidget ): _ ,_ ,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 + + subfolders_by_known_txt_enabled = getattr(self, 'use_subfolders_checkbox', None) and self.use_subfolders_checkbox.isChecked() + subfolder_per_post_enabled = getattr(self, 'use_subfolder_per_post_checkbox', None) and self.use_subfolder_per_post_checkbox.isChecked() + any_subfolder_option_enabled = subfolders_by_known_txt_enabled or subfolder_per_post_enabled not_only_links_or_archives_mode =not ( (self .radio_only_links and self .radio_only_links .isChecked ())or @@ -2303,7 +2449,7 @@ class DownloaderApp (QWidget ): (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 + should_show_custom_folder =is_single_post_url and any_subfolder_option_enabled and not_only_links_or_archives_mode if self .custom_folder_widget : self .custom_folder_widget .setVisible (should_show_custom_folder ) @@ -2383,7 +2529,7 @@ class DownloaderApp (QWidget ): 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")) + self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_original_text","Date + Original")) 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")) @@ -2400,7 +2546,6 @@ class DownloaderApp (QWidget ): 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).") def _toggle_manga_filename_style (self ): @@ -2516,8 +2661,7 @@ class DownloaderApp (QWidget ): show_date_prefix_input =( manga_mode_effectively_on and - (current_filename_style ==STYLE_DATE_BASED or - current_filename_style ==STYLE_ORIGINAL_NAME )and + (current_filename_style ==STYLE_DATE_BASED) and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ) ) if hasattr (self ,'manga_date_prefix_input'): @@ -2607,6 +2751,12 @@ class DownloaderApp (QWidget ): self .file_progress_label .setText ("") def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False): + if self.active_update_profile: + if not self.new_posts_for_update: + return self._check_for_updates() + else: + return self._start_confirmed_update_download() + self.is_finishing = False self.downloaded_hash_counts.clear() global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER @@ -2724,6 +2874,52 @@ class DownloaderApp (QWidget ): return True self.cancellation_message_logged_this_session = False + + service, user_id, post_id_from_url = extract_post_info(api_url) + if not service or not user_id: + QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.") + return False + + # Read the setting at the start of the download + self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) + + profile_processed_ids = set() # Default to an empty set + + if self.save_creator_json_enabled_this_session: + # --- CREATOR PROFILE LOGIC --- + creator_name_for_profile = None + if self.is_processing_favorites_queue and self.current_processing_favorite_item_info: + creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder') + else: + creator_key = (service.lower(), str(user_id)) + creator_name_for_profile = self.creator_name_cache.get(creator_key) + + if not creator_name_for_profile: + creator_name_for_profile = f"{service}_{user_id}" + self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.") + + creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path) + + # Get all current UI settings and add them to the profile + current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run) + creator_profile_data['settings'] = current_settings + + creator_profile_data.setdefault('creator_url', []) + if api_url not in creator_profile_data['creator_url']: + creator_profile_data['creator_url'].append(api_url) + + creator_profile_data.setdefault('processed_post_ids', []) + self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path) + self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.") + + profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) + # --- END OF PROFILE LOGIC --- + + # The rest of this logic runs regardless, but uses the profile data if it was loaded + session_processed_ids = set(processed_post_ids_for_restore) + combined_processed_ids = session_processed_ids.union(profile_processed_ids) + processed_post_ids_for_this_run = list(combined_processed_ids) + use_subfolders = self.use_subfolders_checkbox.isChecked() use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked() compress_images = self.compress_images_checkbox.isChecked() @@ -2831,15 +3027,6 @@ class DownloaderApp (QWidget ): if backend_filter_mode == 'audio': effective_skip_zip = self.skip_zip_checkbox.isChecked() - if not api_url: - QMessageBox.critical(self, "Input Error", "URL is required.") - return False - - service, user_id, post_id_from_url = extract_post_info(api_url) - if not service or not user_id: - QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.") - return False - creator_folder_ignore_words_for_run = None is_full_creator_download = not post_id_from_url @@ -3187,7 +3374,7 @@ class DownloaderApp (QWidget ): 'downloaded_hash_counts': self.downloaded_hash_counts, 'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock, 'skip_current_file_flag': None, - 'processed_post_ids': processed_post_ids_for_restore, + 'processed_post_ids': processed_post_ids_for_this_run, 'start_offset': start_offset_for_restore, } @@ -3232,6 +3419,7 @@ class DownloaderApp (QWidget ): self.is_paused = False return True + def restore_download(self): """Initiates the download restoration process.""" if self._is_download_active(): @@ -3618,7 +3806,10 @@ class DownloaderApp (QWidget ): if permanent: self.permanently_failed_files_for_dialog.extend(permanent) self._update_error_button_count() - if history_data: self._add_to_history_candidates(history_data) + + if history_data: + # This single call now correctly handles both history and profile saving. + self._add_to_history_candidates(history_data) self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) @@ -3667,13 +3858,28 @@ class DownloaderApp (QWidget ): 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.""" - 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 _add_to_history_candidates(self, history_data): + """Adds processed post data to the history candidates list and updates the creator profile.""" + if self.save_creator_json_enabled_this_session: + post_id = history_data.get('post_id') + service = history_data.get('service') + user_id = history_data.get('user_id') + if post_id and service and user_id: + creator_key = (service.lower(), str(user_id)) + creator_name = self.creator_name_cache.get(creator_key, f"{service}_{user_id}") + + # Load the profile data before using it to prevent NameError + profile_data = self._setup_creator_profile(creator_name, self.session_file_path) + + if post_id not in profile_data.get('processed_post_ids', []): + profile_data.setdefault('processed_post_ids', []).append(post_id) + self._save_creator_profile(creator_name, profile_data, self.session_file_path) + + 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. @@ -3864,8 +4070,8 @@ class DownloaderApp (QWidget ): 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 .download_thumbnails_checkbox .setChecked (False ); - self .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (True ); - self .use_subfolder_per_post_checkbox .setChecked (False );self .use_multithreading_checkbox .setChecked (True ); + self .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (False ); + self .use_subfolder_per_post_checkbox .setChecked (True );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 ) @@ -4121,6 +4327,7 @@ class DownloaderApp (QWidget ): self.set_ui_enabled(True) self._update_button_states_and_connections() self.cancellation_message_logged_this_session = False + self.active_update_profile = None def _handle_keep_duplicates_toggled(self, checked): """Shows the duplicate handling dialog when the checkbox is checked.""" @@ -4372,55 +4579,40 @@ class DownloaderApp (QWidget ): if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")) def reset_application_state(self): - if self._is_download_active(): - 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 - 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 = [] - 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 - 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 + self.log_signal.emit("🔄 Resetting application state to defaults...") + + # --- MODIFIED PART: Signal all threads to stop first, but do not wait --- + if self._is_download_active(): + self.log_signal.emit(" Cancelling all active background tasks for reset...") + self.cancellation_event.set() # Signal all threads to stop + + # Initiate non-blocking shutdowns + if self.download_thread and self.download_thread.isRunning(): + self.download_thread.requestInterruption() + if self.thread_pool: + self.thread_pool.shutdown(wait=False, cancel_futures=True) + self.thread_pool = None + if self.external_link_download_thread and self.external_link_download_thread.isRunning(): + self.external_link_download_thread.cancel() + if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool: + self.retry_thread_pool.shutdown(wait=False, cancel_futures=True) + self.retry_thread_pool = None + + self.cancellation_event.clear() # Reset the event for the next run + # --- END OF MODIFIED PART --- + + if self.pause_event: + self.pause_event.clear() + self.is_paused = False - self.log_signal.emit("🔄 Resetting application state to defaults...") self._clear_session_file() self._reset_ui_to_defaults() self._load_saved_download_location() self.main_log_output.clear() self.external_log_output.clear() - 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) @@ -4448,7 +4640,7 @@ class DownloaderApp (QWidget ): 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._update_error_button_count() self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION self._update_favorite_scope_button_text() self.retryable_failed_files_info.clear() @@ -4463,13 +4655,14 @@ class DownloaderApp (QWidget ): self.is_fetcher_thread_running = False self.interrupted_session_data = None self.is_restore_pending = False - + + self.active_update_profile = None 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 @@ -4477,7 +4670,7 @@ class DownloaderApp (QWidget ): self.favorite_download_queue.clear() self.interrupted_session_data = None self.is_restore_pending = False - self.last_link_input_text_for_queue_sync = "" + self.last_link_input_text_for_queue_sync = "" def _reset_ui_to_defaults(self): """Resets all UI elements and relevant state to their default values.""" @@ -4498,8 +4691,8 @@ class DownloaderApp (QWidget ): self.skip_zip_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_subfolders_checkbox.setChecked(False) + self.use_subfolder_per_post_checkbox.setChecked(True) self.use_multithreading_checkbox.setChecked(True) if self.favorite_mode_checkbox: self.favorite_mode_checkbox.setChecked(False) @@ -4773,6 +4966,144 @@ class DownloaderApp (QWidget ): self ._update_favorite_scope_button_text () self .log_signal .emit (f"ℹ️ Favorite download scope changed to: '{self .favorite_download_scope }'") + def _check_for_updates(self): + """Phase 1 of Update: Fetches all posts, compares, and prompts the user for confirmation.""" + self.log_signal.emit("🔄 Checking for updates...") + + update_url = self.active_update_profile['creator_url'][0] + processed_ids_from_profile = set(self.active_update_profile['processed_post_ids']) + self.log_signal.emit(f" Checking URL: {update_url}") + + self.set_ui_enabled(False) + self.progress_label.setText(self._tr("progress_fetching_all_posts", "Progress: Fetching all post pages...")) + QCoreApplication.processEvents() + + try: + post_generator = download_from_api( + api_url_input=update_url, + logger=lambda msg: None, # Suppress noisy logs during check + cancellation_event=self.cancellation_event, + pause_event=self.pause_event, + use_cookie=self.use_cookie_checkbox.isChecked(), + cookie_text=self.cookie_text_input.text(), + selected_cookie_file=self.selected_cookie_filepath, + app_base_dir=self.app_base_dir, + processed_post_ids=processed_ids_from_profile + ) + all_posts_from_api = [post for batch in post_generator for post in batch] + except Exception as e: + self.log_signal.emit(f"❌ Failed to fetch posts during update check: {e}") + self.download_finished(0, 0, False, []) + return + + self.log_signal.emit(f" Fetched a total of {len(all_posts_from_api)} posts from the server.") + + self.new_posts_for_update = [post for post in all_posts_from_api if post.get('id') not in processed_ids_from_profile] + + if not self.new_posts_for_update: + self.log_signal.emit("✅ Creator is up to date! No new posts found.") + QMessageBox.information(self, "Up to Date", "No new posts were found for this creator.") + self._clear_update_selection() + self.set_ui_enabled(True) + self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle")) + return + + self.log_signal.emit(f" Found {len(self.new_posts_for_update)} new post(s). Waiting for user confirmation to download.") + self.progress_label.setText(f"Found {len(self.new_posts_for_update)} new post(s). Ready to download.") + self._update_button_states_and_connections() + + def _start_confirmed_update_download(self): + """Phase 2 of Update: Starts the download of posts found during the check.""" + self.log_signal.emit(f"✅ User confirmed. Starting download for {len(self.new_posts_for_update)} new post(s).") + self.main_log_output.clear() + + from src.config.constants import FOLDER_NAME_STOP_WORDS + update_url = self.active_update_profile['creator_url'][0] + service, user_id, _ = extract_post_info(update_url) + + # --- FIX: Use the BASE download path, not the creator-specific one --- + # Get the base path from the UI (e.g., "E:/Kemono"). The worker will create subfolders inside this. + base_download_dir_from_ui = self.dir_input.text().strip() + self.log_signal.emit(f" Update session will save to base folder: {base_download_dir_from_ui}") + # --- END FIX --- + + raw_character_filters_text = self.character_input.text().strip() + parsed_character_filter_objects = self._parse_character_filters(raw_character_filters_text) + + # --- FIX: Set paths to mimic a normal download, allowing the worker to create subfolders --- + # 'download_root' is the base directory. + # 'override_output_dir' is None, which allows the worker to use its own folder logic. + args_template = { + 'api_url_input': update_url, + 'download_root': base_download_dir_from_ui, # Corrected: Use the BASE path + 'override_output_dir': None, # Corrected: Set to None to allow subfolder logic + 'known_names': list(KNOWN_NAMES), + 'filter_character_list': parsed_character_filter_objects, + 'emitter': self.worker_to_gui_queue, + 'unwanted_keywords': FOLDER_NAME_STOP_WORDS, + 'filter_mode': self.get_filter_mode(), + 'skip_zip': self.skip_zip_checkbox.isChecked(), + 'use_subfolders': self.use_subfolders_checkbox.isChecked(), + 'use_post_subfolders': self.use_subfolder_per_post_checkbox.isChecked(), + 'target_post_id_from_initial_url': None, + 'custom_folder_name': self.custom_folder_input.text().strip(), + 'compress_images': self.compress_images_checkbox.isChecked(), + 'download_thumbnails': self.download_thumbnails_checkbox.isChecked(), + 'service': service, 'user_id': user_id, '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, + 'dynamic_character_filter_holder': self.dynamic_character_filter_holder, + '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(), + 'show_external_links': self.external_links_checkbox.isChecked(), + 'extract_links_only': self.radio_only_links.isChecked(), + 'skip_current_file_flag': None, + 'manga_mode_active': self.manga_mode_checkbox.isChecked(), + 'manga_filename_style': self.manga_filename_style, + '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()], + 'allow_multipart_download': self.allow_multipart_download_setting, + 'cookie_text': self.cookie_text_input.text(), + 'use_cookie': self.use_cookie_checkbox.isChecked(), + 'selected_cookie_file': self.selected_cookie_filepath, + 'app_base_dir': self.app_base_dir, + 'manga_date_prefix': self.manga_date_prefix_input.text().strip(), + 'manga_date_file_counter_ref': None, + 'scan_content_for_images': self.scan_content_images_checkbox.isChecked(), + 'creator_download_folder_ignore_words': None, + 'manga_global_file_counter_ref': None, + 'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked(), + 'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked(), + 'keep_duplicates_mode': self.keep_duplicates_mode, + 'keep_duplicates_limit': self.keep_duplicates_limit, + 'downloaded_hash_counts': self.downloaded_hash_counts, + 'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock, + 'session_file_path': self.session_file_path, + 'session_lock': self.session_lock, + 'text_only_scope': self.more_filter_scope, + 'text_export_format': self.text_export_format, + 'single_pdf_mode': self.single_pdf_setting, + 'project_root_dir': self.app_base_dir, + 'processed_post_ids': list(self.active_update_profile['processed_post_ids']) + } + + num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1 + self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='UpdateWorker_') + self.total_posts_to_process = len(self.new_posts_for_update) + self.processed_posts_count = 0 + self.overall_progress_signal.emit(self.total_posts_to_process, 0) + + ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:] + + for post_data in self.new_posts_for_update: + self._submit_post_to_worker_pool( + post_data, args_template, 1, self.worker_to_gui_queue, ppw_expected_keys, {} + ) + return True + def _show_empty_popup (self ): """Creates and shows the empty popup dialog.""" if self.is_restore_pending: @@ -4782,7 +5113,21 @@ class DownloaderApp (QWidget ): return 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: + if dialog.update_profile_data: + self.active_update_profile = dialog.update_profile_data + self.link_input.setText(dialog.update_creator_name) + self.favorite_download_queue.clear() + + if 'settings' in self.active_update_profile: + self.log_signal.emit(f"ℹ️ Applying saved settings from '{dialog.update_creator_name}' profile...") + self._load_ui_from_settings_dict(self.active_update_profile['settings']) + self.log_signal.emit(" Settings restored.") + + self.log_signal.emit(f"ℹ️ Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.") + self._update_button_states_and_connections() + + elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: + self.active_update_profile = None self.favorite_download_queue.clear() for creator_data in dialog.selected_creators_for_queue: diff --git a/src/utils/resolution.py b/src/utils/resolution.py index 6ee4cf6..746f2fa 100644 --- a/src/utils/resolution.py +++ b/src/utils/resolution.py @@ -239,16 +239,23 @@ def setup_ui(main_app): checkboxes_group_layout.addWidget(advanced_settings_label) advanced_row1_layout = QHBoxLayout() advanced_row1_layout.setSpacing(10) - main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt") - main_app.use_subfolders_checkbox.setChecked(True) - main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders) - advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox) + + # --- REORDERED CHECKBOXES --- main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post") main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders) + main_app.use_subfolder_per_post_checkbox.setChecked(True) advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox) + main_app.date_prefix_checkbox = QCheckBox("Date Prefix") main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") advanced_row1_layout.addWidget(main_app.date_prefix_checkbox) + + main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt") + main_app.use_subfolders_checkbox.setChecked(False) + main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders) + advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox) + # --- END REORDER --- + main_app.use_cookie_checkbox = QCheckBox("Use Cookie") main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting) main_app.cookie_text_input = QLineEdit() @@ -380,7 +387,7 @@ def setup_ui(main_app): main_app.link_search_input.setPlaceholderText("Search Links...") main_app.link_search_input.setVisible(False) log_title_layout.addWidget(main_app.link_search_input) - main_app.link_search_button = QPushButton("🔍") + main_app.link_search_button = QPushButton("�") main_app.link_search_button.setVisible(False) main_app.link_search_button.setFixedWidth(int(30 * scale)) log_title_layout.addWidget(main_app.link_search_button)