From e5b519d5ce5cd413bb4c7d3c7b485fb526d4dff5 Mon Sep 17 00:00:00 2001 From: Yuvi9587 <114073886+Yuvi9587@users.noreply.github.com> Date: Fri, 1 Aug 2025 06:33:36 -0700 Subject: [PATCH] Commit --- src/core/workers.py | 96 +++++++++++---- src/ui/dialogs/MultipartScopeDialog.py | 118 +++++++++++++++++++ src/ui/dialogs/SinglePDF.py | 40 ++++--- src/ui/main_window.py | 154 ++++++++++++------------- 4 files changed, 288 insertions(+), 120 deletions(-) create mode 100644 src/ui/dialogs/MultipartScopeDialog.py diff --git a/src/core/workers.py b/src/core/workers.py index c52e0e9..23a82d8 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -54,6 +54,24 @@ from ..utils.text_utils import ( ) from ..config.constants import * +def robust_clean_name(name): + """A more robust function to remove illegal characters for filenames and folders.""" + if not name: + return "" + # Removes illegal characters for Windows, macOS, and Linux: < > : " / \ | ? * + # Also removes control characters (ASCII 0-31) which are invisible but invalid. + illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*]' + cleaned_name = re.sub(illegal_chars_pattern, '', name) + + # Remove leading/trailing spaces or periods, which can cause issues. + cleaned_name = cleaned_name.strip(' .') + + # If the name is empty after cleaning (e.g., it was only illegal chars), + # provide a safe fallback name. + if not cleaned_name: + return "untitled_folder" # Or "untitled_file" depending on context + return cleaned_name + class PostProcessorSignals (QObject ): progress_signal =pyqtSignal (str ) file_download_status_signal =pyqtSignal (bool ) @@ -64,7 +82,6 @@ class PostProcessorSignals (QObject ): worker_finished_signal = pyqtSignal(tuple) class PostProcessorWorker: - def __init__(self, post_data, download_root, known_names, filter_character_list, emitter, unwanted_keywords, filter_mode, skip_zip, @@ -104,7 +121,10 @@ class PostProcessorWorker: text_export_format='txt', single_pdf_mode=False, project_root_dir=None, - processed_post_ids=None + processed_post_ids=None, + multipart_scope='both', + multipart_parts_count=4, + multipart_min_size_mb=100 ): self.post = post_data self.download_root = download_root @@ -166,7 +186,9 @@ class PostProcessorWorker: self.single_pdf_mode = single_pdf_mode self.project_root_dir = project_root_dir self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] - + self.multipart_scope = multipart_scope + self.multipart_parts_count = multipart_parts_count + self.multipart_min_size_mb = multipart_min_size_mb if self.compress_images and Image is None: self.logger("⚠️ Image compression disabled: Pillow library not found.") self.compress_images = False @@ -201,7 +223,7 @@ class PostProcessorWorker: return self .dynamic_filter_holder .get_filters () return self .filter_character_list_objects_initial - def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event, + def _download_single_file(self, file_info, target_folder_path, post_page_url, original_post_id_for_log, skip_event, post_title="", file_index_in_post=0, num_files_in_this_post=1, manga_date_file_counter_ref=None, forced_filename_override=None, @@ -260,7 +282,7 @@ class PostProcessorWorker: was_original_name_kept_flag = True elif self.manga_filename_style == STYLE_POST_TITLE: if post_title and post_title.strip(): - cleaned_post_title_base = clean_filename(post_title.strip()) + cleaned_post_title_base = robust_clean_name(post_title.strip()) if num_files_in_this_post > 1: if file_index_in_post == 0: filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}" @@ -330,7 +352,7 @@ class PostProcessorWorker: self.logger(f" ⚠️ Post ID {original_post_id_for_log} missing both 'published' and 'added' dates for STYLE_DATE_POST_TITLE. Using 'nodate'.") if post_title and post_title.strip(): - temp_cleaned_title = clean_filename(post_title.strip()) + temp_cleaned_title = robust_clean_name(post_title.strip()) if not temp_cleaned_title or temp_cleaned_title.startswith("untitled_file"): self.logger(f"⚠️ Manga mode (Date+PostTitle Style): Post title for post {original_post_id_for_log} ('{post_title}') was empty or generic after cleaning. Using 'post' as title part.") cleaned_post_title_for_filename = "post" @@ -415,7 +437,7 @@ class PostProcessorWorker: if os.path.exists(final_save_path_check): try: # Use a HEAD request to get the expected size without downloading the body - with requests.head(file_url, headers=headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response: + with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response: head_response.raise_for_status() expected_size = int(head_response.headers.get('Content-Length', -1)) @@ -443,6 +465,11 @@ class PostProcessorWorker: except requests.RequestException as e: self.logger(f" ⚠️ Could not verify size of existing file '{filename_to_save_in_main_path}': {e}. Proceeding with download.") + file_download_headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36', + 'Referer': post_page_url + } + retry_delay = 5 downloaded_size_bytes = 0 calculated_file_hash = None @@ -463,13 +490,31 @@ class PostProcessorWorker: self.logger(f" Retrying download for '{api_original_filename}' (Overall Attempt {attempt_num_single_stream + 1}/{max_retries + 1})...") time.sleep(retry_delay * (2 ** (attempt_num_single_stream - 1))) self._emit_signal('file_download_status', True) - response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file) + response = requests.get(file_url, headers=file_download_headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file) + response.raise_for_status() total_size_bytes = int(response.headers.get('Content-Length', 0)) - num_parts_for_file = min(self.num_file_threads, MAX_PARTS_FOR_MULTIPART_DOWNLOAD) + # Use the dedicated parts count from the dialog, not the main thread count + num_parts_for_file = min(self.multipart_parts_count, MAX_PARTS_FOR_MULTIPART_DOWNLOAD) + + file_is_eligible_by_scope = False + if self.multipart_scope == 'videos': + if is_video(api_original_filename): + file_is_eligible_by_scope = True + elif self.multipart_scope == 'archives': + if is_archive(api_original_filename): + file_is_eligible_by_scope = True + elif self.multipart_scope == 'both': + if is_video(api_original_filename) or is_archive(api_original_filename): + file_is_eligible_by_scope = True + + min_size_in_bytes = self.multipart_min_size_mb * 1024 * 1024 + attempt_multipart = (self.allow_multipart_download and MULTIPART_DOWNLOADER_AVAILABLE and - num_parts_for_file > 1 and total_size_bytes > MIN_SIZE_FOR_MULTIPART_DOWNLOAD and + file_is_eligible_by_scope and + num_parts_for_file > 1 and total_size_bytes > min_size_in_bytes and 'bytes' in response.headers.get('Accept-Ranges', '').lower()) + if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break if attempt_multipart: @@ -478,7 +523,7 @@ class PostProcessorWorker: response_for_this_attempt = None mp_save_path_for_unique_part_stem_arg = os.path.join(target_folder_path, f"{unique_part_file_stem_on_disk}{temp_file_ext_for_unique_part}") mp_success, mp_bytes, mp_hash, mp_file_handle = download_file_in_parts( - file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, headers, api_original_filename, + file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, file_download_headers, api_original_filename, emitter_for_multipart=self.emitter, cookies_for_chunk_session=cookies_to_use_for_file, cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger, pause_event=self.pause_event @@ -553,12 +598,15 @@ class PostProcessorWorker: if isinstance(e, requests.exceptions.ConnectionError) and ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") except requests.exceptions.RequestException as e: - self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}") - last_exception_for_retry_later = e - is_permanent_error = True - if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): - self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") - break + if e.response is not None and e.response.status_code == 403: + self.logger(f" ⚠️ Download Error (403 Forbidden): {api_original_filename}. This often requires valid cookies.") + self.logger(f" Will retry... Check your 'Use Cookie' settings if this persists.") + last_exception_for_retry_later = e + else: + self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}") + last_exception_for_retry_later = e + is_permanent_error = True + break except Exception as e: self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}") last_exception_for_retry_later = e @@ -728,7 +776,7 @@ class PostProcessorWorker: self.logger(f" -> Failed to remove partially saved file: {final_save_path}") permanent_failure_details = { - 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, + 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': file_download_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, @@ -742,7 +790,7 @@ class PostProcessorWorker: details_for_failure = { 'file_info': file_info, 'target_folder_path': target_folder_path, - 'headers': headers, + 'headers': file_download_headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, @@ -1044,7 +1092,7 @@ class PostProcessorWorker: determined_post_save_path_for_history = os.path.join(determined_post_save_path_for_history, base_folder_names_for_post_content[0]) if not self.extract_links_only and self.use_post_subfolders: - cleaned_post_title_for_sub = clean_folder_name(post_title) + cleaned_post_title_for_sub = robust_clean_name(post_title) post_id_for_fallback = self.post.get('id', 'unknown_id') if not cleaned_post_title_for_sub or cleaned_post_title_for_sub == "untitled_folder": @@ -1626,7 +1674,7 @@ class PostProcessorWorker: self._download_single_file, file_info=file_info_to_dl, target_folder_path=current_path_for_file_instance, - headers=headers, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag, + post_page_url=post_page_url, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag, post_title=post_title, manga_date_file_counter_ref=manga_date_counter_to_pass, manga_global_file_counter_ref=manga_global_counter_to_pass, folder_context_name_for_history=folder_context_for_file, file_index_in_post=file_idx, num_files_in_this_post=len(files_to_download_info_list) @@ -1783,6 +1831,8 @@ class DownloadThread(QThread): remove_from_filename_words_list=None, manga_date_prefix='', allow_multipart_download=True, + multipart_parts_count=4, + multipart_min_size_mb=100, selected_cookie_file=None, override_output_dir=None, app_base_dir=None, @@ -1845,6 +1895,8 @@ class DownloadThread(QThread): self.remove_from_filename_words_list = remove_from_filename_words_list self.manga_date_prefix = manga_date_prefix self.allow_multipart_download = allow_multipart_download + self.multipart_parts_count = multipart_parts_count + self.multipart_min_size_mb = multipart_min_size_mb self.selected_cookie_file = selected_cookie_file self.app_base_dir = app_base_dir self.cookie_text = cookie_text @@ -1986,6 +2038,8 @@ class DownloadThread(QThread): 'text_only_scope': self.text_only_scope, 'text_export_format': self.text_export_format, 'single_pdf_mode': self.single_pdf_mode, + 'multipart_parts_count': self.multipart_parts_count, + 'multipart_min_size_mb': self.multipart_min_size_mb, 'project_root_dir': self.project_root_dir, } diff --git a/src/ui/dialogs/MultipartScopeDialog.py b/src/ui/dialogs/MultipartScopeDialog.py new file mode 100644 index 0000000..01cdcb8 --- /dev/null +++ b/src/ui/dialogs/MultipartScopeDialog.py @@ -0,0 +1,118 @@ +# multipart_scope_dialog.py +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QGroupBox, QRadioButton, QDialogButtonBox, QButtonGroup, + QLabel, QLineEdit, QHBoxLayout, QFrame +) +from PyQt5.QtGui import QIntValidator +from PyQt5.QtCore import Qt + +# It's good practice to get this constant from the source +# but for this example, we will define it here. +MAX_PARTS = 16 + +class MultipartScopeDialog(QDialog): + """ + A dialog to let the user select the scope, number of parts, and minimum size for multipart downloads. + """ + SCOPE_VIDEOS = 'videos' + SCOPE_ARCHIVES = 'archives' + SCOPE_BOTH = 'both' + + def __init__(self, current_scope='both', current_parts=4, current_min_size_mb=100, parent=None): + super().__init__(parent) + self.setWindowTitle("Multipart Download Options") + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + self.setMinimumWidth(350) + + # Main Layout + layout = QVBoxLayout(self) + + # --- Options Group for Scope --- + self.options_group_box = QGroupBox("Apply multipart downloads to:") + options_layout = QVBoxLayout() + # ... (Radio buttons and button group code remains unchanged) ... + self.radio_videos = QRadioButton("Videos Only") + self.radio_archives = QRadioButton("Archives Only (.zip, .rar, etc.)") + self.radio_both = QRadioButton("Both Videos and Archives") + + if current_scope == self.SCOPE_VIDEOS: + self.radio_videos.setChecked(True) + elif current_scope == self.SCOPE_ARCHIVES: + self.radio_archives.setChecked(True) + else: + self.radio_both.setChecked(True) + + self.button_group = QButtonGroup(self) + self.button_group.addButton(self.radio_videos) + self.button_group.addButton(self.radio_archives) + self.button_group.addButton(self.radio_both) + + options_layout.addWidget(self.radio_videos) + options_layout.addWidget(self.radio_archives) + options_layout.addWidget(self.radio_both) + self.options_group_box.setLayout(options_layout) + layout.addWidget(self.options_group_box) + + # --- START: MODIFIED Download Settings Group --- + self.settings_group_box = QGroupBox("Download settings:") + settings_layout = QVBoxLayout() + + # Layout for Parts count + parts_layout = QHBoxLayout() + self.parts_label = QLabel("Number of download parts per file:") + self.parts_input = QLineEdit(str(current_parts)) + self.parts_input.setValidator(QIntValidator(2, MAX_PARTS, self)) + self.parts_input.setFixedWidth(40) + self.parts_input.setToolTip(f"Set the number of concurrent connections per file (2-{MAX_PARTS}).") + parts_layout.addWidget(self.parts_label) + parts_layout.addStretch() + parts_layout.addWidget(self.parts_input) + settings_layout.addLayout(parts_layout) + + # Layout for Minimum Size + size_layout = QHBoxLayout() + self.size_label = QLabel("Minimum file size for multipart (MB):") + self.size_input = QLineEdit(str(current_min_size_mb)) + self.size_input.setValidator(QIntValidator(10, 10000, self)) # Min 10MB, Max ~10GB + self.size_input.setFixedWidth(40) + self.size_input.setToolTip("Files smaller than this will use a normal, single-part download.") + size_layout.addWidget(self.size_label) + size_layout.addStretch() + size_layout.addWidget(self.size_input) + settings_layout.addLayout(size_layout) + + self.settings_group_box.setLayout(settings_layout) + layout.addWidget(self.settings_group_box) + # --- END: MODIFIED Download Settings Group --- + + # OK and Cancel Buttons + 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 get_selected_scope(self): + # ... (This method remains unchanged) ... + if self.radio_videos.isChecked(): + return self.SCOPE_VIDEOS + if self.radio_archives.isChecked(): + return self.SCOPE_ARCHIVES + return self.SCOPE_BOTH + + def get_selected_parts(self): + # ... (This method remains unchanged) ... + try: + parts = int(self.parts_input.text()) + return max(2, min(parts, MAX_PARTS)) + except (ValueError, TypeError): + return 4 + + def get_selected_min_size(self): + """Returns the selected minimum size in MB as an integer.""" + try: + size = int(self.size_input.text()) + return max(10, min(size, 10000)) # Enforce valid range + except (ValueError, TypeError): + return 100 # Return a safe default \ No newline at end of file diff --git a/src/ui/dialogs/SinglePDF.py b/src/ui/dialogs/SinglePDF.py index 7bef00c..1c0e1f5 100644 --- a/src/ui/dialogs/SinglePDF.py +++ b/src/ui/dialogs/SinglePDF.py @@ -3,8 +3,27 @@ import re try: from fpdf import FPDF FPDF_AVAILABLE = True + + # --- FIX: Move the class definition inside the try block --- + class PDF(FPDF): + """Custom PDF class to handle headers and footers.""" + def header(self): + pass + + def footer(self): + self.set_y(-15) + if self.font_family: + self.set_font(self.font_family, '', 8) + else: + self.set_font('Arial', '', 8) + self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') + except ImportError: FPDF_AVAILABLE = False + # If the import fails, FPDF and PDF will not be defined, + # but the program won't crash here. + FPDF = None + PDF = None def strip_html_tags(text): if not text: @@ -12,19 +31,6 @@ def strip_html_tags(text): clean = re.compile('<.*?>') return re.sub(clean, '', text) -class PDF(FPDF): - """Custom PDF class to handle headers and footers.""" - def header(self): - pass - - def footer(self): - self.set_y(-15) - if self.font_family: - self.set_font(self.font_family, '', 8) - else: - self.set_font('Arial', '', 8) - 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, continuous PDF, correctly formatting both descriptions and comments. @@ -68,7 +74,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge pdf.ln(10) pdf.set_font(default_font_family, 'B', 16) - pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L') + pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L') pdf.ln(5) if 'comments' in post and post['comments']: @@ -89,7 +95,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge pdf.ln(10) pdf.set_font(default_font_family, '', 11) - pdf.multi_cell(0, 7, body) + pdf.multi_cell(w=0, h=7, txt=body) if comment_index < len(comments_list) - 1: pdf.ln(3) @@ -97,7 +103,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge pdf.ln(3) elif 'content' in post: pdf.set_font(default_font_family, '', 12) - pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) + pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content')) try: pdf.output(output_filename) @@ -105,4 +111,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge return True except Exception as e: logger(f"❌ A critical error occurred while saving the final PDF: {e}") - return False \ No newline at end of file + return False diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 4f30ad2..423a2a9 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -58,6 +58,7 @@ from .dialogs.MoreOptionsDialog import MoreOptionsDialog from .dialogs.SinglePDF import create_single_pdf_from_content from .dialogs.SupportDialog import SupportDialog from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog +from .dialogs.MultipartScopeDialog import MultipartScopeDialog class DynamicFilterHolder: """A thread-safe class to hold and update character filters during a download.""" @@ -222,6 +223,9 @@ class DownloaderApp (QWidget ): self.only_links_log_display_mode = LOG_DISPLAY_LINKS self.mega_download_log_preserved_once = False self.allow_multipart_download_setting = False + self.multipart_scope = 'both' + self.multipart_parts_count = 4 + self.multipart_min_size_mb = 100 self.use_cookie_setting = False self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool) self.cookie_text_setting = "" @@ -236,7 +240,8 @@ class DownloaderApp (QWidget ): self.session_temp_files = [] self.single_pdf_mode = False self.save_creator_json_enabled_this_session = True - + self.is_single_post_session = False + print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}") try: @@ -267,7 +272,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.2.1") + self.setWindowTitle("Kemono Downloader v6.3.0") setup_ui(self) self._connect_signals() self.log_signal.emit("ℹ️ Local API server functionality has been removed.") @@ -2689,16 +2694,13 @@ class DownloaderApp (QWidget ): url_text =self .link_input .text ().strip ()if self .link_input else "" _ ,_ ,post_id =extract_post_info (url_text ) - # --- START: MODIFIED LOGIC --- is_creator_feed =not post_id if url_text else False is_single_post = bool(post_id) is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False - # If the download queue contains items selected from the popup, treat it as a single-post context for UI purposes. if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue): is_single_post = True - # Allow Manga Mode checkbox for any valid URL (creator or single post) or if single posts are queued. can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on if self .manga_mode_checkbox : @@ -2709,12 +2711,10 @@ class DownloaderApp (QWidget ): manga_mode_effectively_on = can_enable_manga_checkbox and checked - # If it's a single post context, prevent sequential styles from being selected as they don't apply. sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] if is_single_post and self.manga_filename_style in sequential_styles: - self.manga_filename_style = STYLE_POST_TITLE # Default to a safe, non-sequential style + self.manga_filename_style = STYLE_POST_TITLE self._update_manga_filename_style_button_text() - # --- END: MODIFIED LOGIC --- 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 )) @@ -2748,11 +2748,9 @@ class DownloaderApp (QWidget ): 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 )) + 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 + self.multipart_toggle_button.setVisible(not hide_multipart_button_due_mode) self ._update_multithreading_for_date_mode () @@ -2953,9 +2951,6 @@ class DownloaderApp (QWidget ): service, user_id, post_id_from_url = extract_post_info(api_url) - # --- START: MODIFIED SECTION --- - # This check is now smarter. It only triggers the error if the item from the queue - # was supposed to be a post ('single_post_from_popup', etc.) but couldn't be parsed. if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue: self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}") self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.") @@ -2966,39 +2961,42 @@ class DownloaderApp (QWidget ): kept_original_names_list=[] ) return False - # --- END: MODIFIED SECTION --- if not service or not user_id: QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.") return False self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) + self.is_single_post_session = bool(post_id_from_url) - creator_profile_data = {} - if self.save_creator_json_enabled_this_session: - 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 self.is_single_post_session: + self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) - 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 = {} + if self.save_creator_json_enabled_this_session: + 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) - creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path) - - 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) + 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.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.") + creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path) + + 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() @@ -3453,6 +3451,9 @@ class DownloaderApp (QWidget ): '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, + 'multipart_scope': self.multipart_scope, + 'multipart_parts_count': self.multipart_parts_count, + 'multipart_min_size_mb': self.multipart_min_size_mb, '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, @@ -3497,7 +3498,7 @@ class DownloaderApp (QWidget ): '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', 'project_root_dir', 'text_only_scope', 'text_export_format', - 'single_pdf_mode', + 'single_pdf_mode','multipart_parts_count', 'multipart_min_size_mb', 'use_date_prefix_for_subfolder','keep_in_post_duplicates', 'keep_duplicates_mode', 'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock', 'processed_post_ids' @@ -3514,7 +3515,6 @@ class DownloaderApp (QWidget ): self.is_paused = False return True - def restore_download(self): """Initiates the download restoration process.""" if self._is_download_active(): @@ -3960,8 +3960,8 @@ class DownloaderApp (QWidget ): self.log_signal.emit("="*40) 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: + """Adds processed post data to the history candidates list and updates the creator profile.""" + if self.save_creator_json_enabled_this_session and not self.is_single_post_session: post_id = history_data.get('post_id') service = history_data.get('service') user_id = history_data.get('user_id') @@ -3969,7 +3969,6 @@ class DownloaderApp (QWidget ): 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', []): @@ -3982,6 +3981,7 @@ class DownloaderApp (QWidget ): 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. @@ -4937,14 +4937,15 @@ class DownloaderApp (QWidget ): 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_multipart_toggle_button_text(self): + if hasattr(self, 'multipart_toggle_button'): + if self.allow_multipart_download_setting: + scope_text = self.multipart_scope.capitalize() + self.multipart_toggle_button.setText(self._tr("multipart_on_button_text", f"Multi-part: {scope_text}")) + self.multipart_toggle_button.setToolTip(self._tr("multipart_on_button_tooltip", f"Multipart download is ON. Applied to: {scope_text} files. Click to change.")) + 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", "Multipart download is OFF. Click to enable and set options.")) def _update_error_button_count(self): """Updates the Error button text to show the count of failed files.""" @@ -4959,36 +4960,25 @@ class DownloaderApp (QWidget ): 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:

" - "
" - "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 _toggle_multipart_mode(self): + """ + Opens the Multipart Scope Dialog and updates settings based on user choice. + """ + current_scope = self.multipart_scope if self.allow_multipart_download_setting else 'both' + dialog = MultipartScopeDialog(current_scope, self.multipart_parts_count, self.multipart_min_size_mb, self) + + if dialog.exec_() == QDialog.Accepted: + self.multipart_scope = dialog.get_selected_scope() + self.multipart_parts_count = dialog.get_selected_parts() + self.multipart_min_size_mb = dialog.get_selected_min_size() # Get the new value + self.allow_multipart_download_setting = True + self.log_signal.emit(f"ℹ️ Multi-part download enabled: Scope='{self.multipart_scope.capitalize()}', Parts={self.multipart_parts_count}, Min Size={self.multipart_min_size_mb} MB") + else: + self.allow_multipart_download_setting = False + self.log_signal.emit("ℹ️ Multi-part download setting remains OFF.") + + self._update_multipart_toggle_button_text() + self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting) def _open_known_txt_file (self ): if not os .path .exists (self .config_file ):