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:
"
- "