mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Commit
This commit is contained in:
@@ -54,6 +54,24 @@ from ..utils.text_utils import (
|
|||||||
)
|
)
|
||||||
from ..config.constants 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 ):
|
class PostProcessorSignals (QObject ):
|
||||||
progress_signal =pyqtSignal (str )
|
progress_signal =pyqtSignal (str )
|
||||||
file_download_status_signal =pyqtSignal (bool )
|
file_download_status_signal =pyqtSignal (bool )
|
||||||
@@ -64,7 +82,6 @@ class PostProcessorSignals (QObject ):
|
|||||||
worker_finished_signal = pyqtSignal(tuple)
|
worker_finished_signal = pyqtSignal(tuple)
|
||||||
|
|
||||||
class PostProcessorWorker:
|
class PostProcessorWorker:
|
||||||
|
|
||||||
def __init__(self, post_data, download_root, known_names,
|
def __init__(self, post_data, download_root, known_names,
|
||||||
filter_character_list, emitter,
|
filter_character_list, emitter,
|
||||||
unwanted_keywords, filter_mode, skip_zip,
|
unwanted_keywords, filter_mode, skip_zip,
|
||||||
@@ -104,7 +121,10 @@ class PostProcessorWorker:
|
|||||||
text_export_format='txt',
|
text_export_format='txt',
|
||||||
single_pdf_mode=False,
|
single_pdf_mode=False,
|
||||||
project_root_dir=None,
|
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.post = post_data
|
||||||
self.download_root = download_root
|
self.download_root = download_root
|
||||||
@@ -166,7 +186,9 @@ class PostProcessorWorker:
|
|||||||
self.single_pdf_mode = single_pdf_mode
|
self.single_pdf_mode = single_pdf_mode
|
||||||
self.project_root_dir = project_root_dir
|
self.project_root_dir = project_root_dir
|
||||||
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else []
|
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:
|
if self.compress_images and Image is None:
|
||||||
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
||||||
self.compress_images = False
|
self.compress_images = False
|
||||||
@@ -201,7 +223,7 @@ class PostProcessorWorker:
|
|||||||
return self .dynamic_filter_holder .get_filters ()
|
return self .dynamic_filter_holder .get_filters ()
|
||||||
return self .filter_character_list_objects_initial
|
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,
|
post_title="", file_index_in_post=0, num_files_in_this_post=1,
|
||||||
manga_date_file_counter_ref=None,
|
manga_date_file_counter_ref=None,
|
||||||
forced_filename_override=None,
|
forced_filename_override=None,
|
||||||
@@ -260,7 +282,7 @@ class PostProcessorWorker:
|
|||||||
was_original_name_kept_flag = True
|
was_original_name_kept_flag = True
|
||||||
elif self.manga_filename_style == STYLE_POST_TITLE:
|
elif self.manga_filename_style == STYLE_POST_TITLE:
|
||||||
if post_title and post_title.strip():
|
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 num_files_in_this_post > 1:
|
||||||
if file_index_in_post == 0:
|
if file_index_in_post == 0:
|
||||||
filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}"
|
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'.")
|
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():
|
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"):
|
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.")
|
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"
|
cleaned_post_title_for_filename = "post"
|
||||||
@@ -415,7 +437,7 @@ class PostProcessorWorker:
|
|||||||
if os.path.exists(final_save_path_check):
|
if os.path.exists(final_save_path_check):
|
||||||
try:
|
try:
|
||||||
# Use a HEAD request to get the expected size without downloading the body
|
# 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()
|
head_response.raise_for_status()
|
||||||
expected_size = int(head_response.headers.get('Content-Length', -1))
|
expected_size = int(head_response.headers.get('Content-Length', -1))
|
||||||
|
|
||||||
@@ -443,6 +465,11 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
except requests.RequestException as e:
|
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.")
|
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
|
retry_delay = 5
|
||||||
downloaded_size_bytes = 0
|
downloaded_size_bytes = 0
|
||||||
calculated_file_hash = None
|
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})...")
|
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)))
|
time.sleep(retry_delay * (2 ** (attempt_num_single_stream - 1)))
|
||||||
self._emit_signal('file_download_status', True)
|
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()
|
response.raise_for_status()
|
||||||
total_size_bytes = int(response.headers.get('Content-Length', 0))
|
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
|
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())
|
'bytes' in response.headers.get('Accept-Ranges', '').lower())
|
||||||
|
|
||||||
if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break
|
if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break
|
||||||
|
|
||||||
if attempt_multipart:
|
if attempt_multipart:
|
||||||
@@ -478,7 +523,7 @@ class PostProcessorWorker:
|
|||||||
response_for_this_attempt = None
|
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_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(
|
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,
|
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,
|
cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger,
|
||||||
pause_event=self.pause_event
|
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)):
|
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.")
|
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
|
if e.response is not None and e.response.status_code == 403:
|
||||||
last_exception_for_retry_later = e
|
self.logger(f" ⚠️ Download Error (403 Forbidden): {api_original_filename}. This often requires valid cookies.")
|
||||||
is_permanent_error = True
|
self.logger(f" Will retry... Check your 'Use Cookie' settings if this persists.")
|
||||||
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
|
last_exception_for_retry_later = e
|
||||||
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
else:
|
||||||
break
|
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:
|
except Exception as e:
|
||||||
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
|
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
|
||||||
last_exception_for_retry_later = e
|
last_exception_for_retry_later = e
|
||||||
@@ -728,7 +776,7 @@ class PostProcessorWorker:
|
|||||||
self.logger(f" -> Failed to remove partially saved file: {final_save_path}")
|
self.logger(f" -> Failed to remove partially saved file: {final_save_path}")
|
||||||
|
|
||||||
permanent_failure_details = {
|
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,
|
'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,
|
'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,
|
'forced_filename_override': filename_to_save_in_main_path,
|
||||||
@@ -742,7 +790,7 @@ class PostProcessorWorker:
|
|||||||
details_for_failure = {
|
details_for_failure = {
|
||||||
'file_info': file_info,
|
'file_info': file_info,
|
||||||
'target_folder_path': target_folder_path,
|
'target_folder_path': target_folder_path,
|
||||||
'headers': headers,
|
'headers': file_download_headers,
|
||||||
'original_post_id_for_log': original_post_id_for_log,
|
'original_post_id_for_log': original_post_id_for_log,
|
||||||
'post_title': post_title,
|
'post_title': post_title,
|
||||||
'file_index_in_post': file_index_in_post,
|
'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])
|
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:
|
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')
|
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":
|
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,
|
self._download_single_file,
|
||||||
file_info=file_info_to_dl,
|
file_info=file_info_to_dl,
|
||||||
target_folder_path=current_path_for_file_instance,
|
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,
|
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,
|
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)
|
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,
|
remove_from_filename_words_list=None,
|
||||||
manga_date_prefix='',
|
manga_date_prefix='',
|
||||||
allow_multipart_download=True,
|
allow_multipart_download=True,
|
||||||
|
multipart_parts_count=4,
|
||||||
|
multipart_min_size_mb=100,
|
||||||
selected_cookie_file=None,
|
selected_cookie_file=None,
|
||||||
override_output_dir=None,
|
override_output_dir=None,
|
||||||
app_base_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.remove_from_filename_words_list = remove_from_filename_words_list
|
||||||
self.manga_date_prefix = manga_date_prefix
|
self.manga_date_prefix = manga_date_prefix
|
||||||
self.allow_multipart_download = allow_multipart_download
|
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.selected_cookie_file = selected_cookie_file
|
||||||
self.app_base_dir = app_base_dir
|
self.app_base_dir = app_base_dir
|
||||||
self.cookie_text = cookie_text
|
self.cookie_text = cookie_text
|
||||||
@@ -1986,6 +2038,8 @@ class DownloadThread(QThread):
|
|||||||
'text_only_scope': self.text_only_scope,
|
'text_only_scope': self.text_only_scope,
|
||||||
'text_export_format': self.text_export_format,
|
'text_export_format': self.text_export_format,
|
||||||
'single_pdf_mode': self.single_pdf_mode,
|
'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,
|
'project_root_dir': self.project_root_dir,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
src/ui/dialogs/MultipartScopeDialog.py
Normal file
118
src/ui/dialogs/MultipartScopeDialog.py
Normal file
@@ -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
|
||||||
@@ -3,8 +3,27 @@ import re
|
|||||||
try:
|
try:
|
||||||
from fpdf import FPDF
|
from fpdf import FPDF
|
||||||
FPDF_AVAILABLE = True
|
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:
|
except ImportError:
|
||||||
FPDF_AVAILABLE = False
|
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):
|
def strip_html_tags(text):
|
||||||
if not text:
|
if not text:
|
||||||
@@ -12,19 +31,6 @@ def strip_html_tags(text):
|
|||||||
clean = re.compile('<.*?>')
|
clean = re.compile('<.*?>')
|
||||||
return re.sub(clean, '', text)
|
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):
|
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.
|
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.ln(10)
|
||||||
|
|
||||||
pdf.set_font(default_font_family, 'B', 16)
|
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)
|
pdf.ln(5)
|
||||||
|
|
||||||
if 'comments' in post and post['comments']:
|
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.ln(10)
|
||||||
|
|
||||||
pdf.set_font(default_font_family, '', 11)
|
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:
|
if comment_index < len(comments_list) - 1:
|
||||||
pdf.ln(3)
|
pdf.ln(3)
|
||||||
@@ -97,7 +103,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
|||||||
pdf.ln(3)
|
pdf.ln(3)
|
||||||
elif 'content' in post:
|
elif 'content' in post:
|
||||||
pdf.set_font(default_font_family, '', 12)
|
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:
|
try:
|
||||||
pdf.output(output_filename)
|
pdf.output(output_filename)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from .dialogs.MoreOptionsDialog import MoreOptionsDialog
|
|||||||
from .dialogs.SinglePDF import create_single_pdf_from_content
|
from .dialogs.SinglePDF import create_single_pdf_from_content
|
||||||
from .dialogs.SupportDialog import SupportDialog
|
from .dialogs.SupportDialog import SupportDialog
|
||||||
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
|
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
|
||||||
|
from .dialogs.MultipartScopeDialog import MultipartScopeDialog
|
||||||
|
|
||||||
class DynamicFilterHolder:
|
class DynamicFilterHolder:
|
||||||
"""A thread-safe class to hold and update character filters during a download."""
|
"""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.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||||
self.mega_download_log_preserved_once = False
|
self.mega_download_log_preserved_once = False
|
||||||
self.allow_multipart_download_setting = 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.use_cookie_setting = False
|
||||||
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool)
|
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool)
|
||||||
self.cookie_text_setting = ""
|
self.cookie_text_setting = ""
|
||||||
@@ -236,6 +240,7 @@ class DownloaderApp (QWidget ):
|
|||||||
self.session_temp_files = []
|
self.session_temp_files = []
|
||||||
self.single_pdf_mode = False
|
self.single_pdf_mode = False
|
||||||
self.save_creator_json_enabled_this_session = True
|
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}")
|
print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}")
|
||||||
|
|
||||||
@@ -267,7 +272,7 @@ class DownloaderApp (QWidget ):
|
|||||||
self.download_location_label_widget = None
|
self.download_location_label_widget = None
|
||||||
self.remove_from_filename_label_widget = None
|
self.remove_from_filename_label_widget = None
|
||||||
self.skip_words_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)
|
setup_ui(self)
|
||||||
self._connect_signals()
|
self._connect_signals()
|
||||||
self.log_signal.emit("ℹ️ Local API server functionality has been removed.")
|
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 ""
|
url_text =self .link_input .text ().strip ()if self .link_input else ""
|
||||||
_ ,_ ,post_id =extract_post_info (url_text )
|
_ ,_ ,post_id =extract_post_info (url_text )
|
||||||
|
|
||||||
# --- START: MODIFIED LOGIC ---
|
|
||||||
is_creator_feed =not post_id if url_text else False
|
is_creator_feed =not post_id if url_text else False
|
||||||
is_single_post = bool(post_id)
|
is_single_post = bool(post_id)
|
||||||
is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
|
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):
|
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
|
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
|
can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on
|
||||||
|
|
||||||
if self .manga_mode_checkbox :
|
if self .manga_mode_checkbox :
|
||||||
@@ -2709,12 +2711,10 @@ class DownloaderApp (QWidget ):
|
|||||||
|
|
||||||
manga_mode_effectively_on = can_enable_manga_checkbox and checked
|
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]
|
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||||
if is_single_post and self.manga_filename_style in sequential_styles:
|
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()
|
self._update_manga_filename_style_button_text()
|
||||||
# --- END: MODIFIED LOGIC ---
|
|
||||||
|
|
||||||
if self .manga_rename_toggle_button :
|
if self .manga_rename_toggle_button :
|
||||||
self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
|
self .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 .setMaximumWidth (16777215 )
|
||||||
self .manga_date_prefix_input .setMinimumWidth (0 )
|
self .manga_date_prefix_input .setMinimumWidth (0 )
|
||||||
|
|
||||||
if hasattr (self ,'multipart_toggle_button'):
|
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_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)
|
||||||
hide_multipart_button_due_manga_mode =manga_mode_effectively_on
|
|
||||||
self .multipart_toggle_button .setVisible (not (hide_multipart_button_due_mode or hide_multipart_button_due_manga_mode ))
|
|
||||||
|
|
||||||
self ._update_multithreading_for_date_mode ()
|
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)
|
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:
|
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(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.")
|
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=[]
|
kept_original_names_list=[]
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
# --- END: MODIFIED SECTION ---
|
|
||||||
|
|
||||||
if not service or not user_id:
|
if not service or not user_id:
|
||||||
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
|
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
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 not self.is_single_post_session:
|
||||||
if self.save_creator_json_enabled_this_session:
|
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||||
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_profile_data = {}
|
||||||
creator_name_for_profile = f"{service}_{user_id}"
|
if self.save_creator_json_enabled_this_session:
|
||||||
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
|
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)
|
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.")
|
||||||
|
|
||||||
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 = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
|
||||||
creator_profile_data['settings'] = current_settings
|
|
||||||
|
|
||||||
creator_profile_data.setdefault('creator_url', [])
|
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
|
||||||
if api_url not in creator_profile_data['creator_url']:
|
creator_profile_data['settings'] = current_settings
|
||||||
creator_profile_data['creator_url'].append(api_url)
|
|
||||||
|
|
||||||
creator_profile_data.setdefault('processed_post_ids', [])
|
creator_profile_data.setdefault('creator_url', [])
|
||||||
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
|
if api_url not in creator_profile_data['creator_url']:
|
||||||
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
|
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()
|
profile_processed_ids = set()
|
||||||
|
|
||||||
@@ -3453,6 +3451,9 @@ class DownloaderApp (QWidget ):
|
|||||||
'num_file_threads_for_worker': effective_num_file_threads_per_worker,
|
'num_file_threads_for_worker': effective_num_file_threads_per_worker,
|
||||||
'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread,
|
'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread,
|
||||||
'allow_multipart_download': allow_multipart,
|
'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,
|
'cookie_text': cookie_text_from_input,
|
||||||
'selected_cookie_file': selected_cookie_file_path_for_backend,
|
'selected_cookie_file': selected_cookie_file_path_for_backend,
|
||||||
'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread,
|
'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',
|
'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',
|
'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',
|
'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',
|
'use_date_prefix_for_subfolder','keep_in_post_duplicates', 'keep_duplicates_mode',
|
||||||
'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock',
|
'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock',
|
||||||
'processed_post_ids'
|
'processed_post_ids'
|
||||||
@@ -3514,7 +3515,6 @@ class DownloaderApp (QWidget ):
|
|||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def restore_download(self):
|
def restore_download(self):
|
||||||
"""Initiates the download restoration process."""
|
"""Initiates the download restoration process."""
|
||||||
if self._is_download_active():
|
if self._is_download_active():
|
||||||
@@ -3961,7 +3961,7 @@ class DownloaderApp (QWidget ):
|
|||||||
|
|
||||||
def _add_to_history_candidates(self, history_data):
|
def _add_to_history_candidates(self, history_data):
|
||||||
"""Adds processed post data to the history candidates list and updates the creator profile."""
|
"""Adds processed post data to the history candidates list and updates the creator profile."""
|
||||||
if self.save_creator_json_enabled_this_session:
|
if self.save_creator_json_enabled_this_session and not self.is_single_post_session:
|
||||||
post_id = history_data.get('post_id')
|
post_id = history_data.get('post_id')
|
||||||
service = history_data.get('service')
|
service = history_data.get('service')
|
||||||
user_id = history_data.get('user_id')
|
user_id = history_data.get('user_id')
|
||||||
@@ -3969,7 +3969,6 @@ class DownloaderApp (QWidget ):
|
|||||||
creator_key = (service.lower(), str(user_id))
|
creator_key = (service.lower(), str(user_id))
|
||||||
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{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)
|
profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
|
||||||
|
|
||||||
if post_id not in profile_data.get('processed_post_ids', []):
|
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'))
|
history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown'))
|
||||||
self.download_history_candidates.append(history_data)
|
self.download_history_candidates.append(history_data)
|
||||||
|
|
||||||
|
|
||||||
def _finalize_download_history (self ):
|
def _finalize_download_history (self ):
|
||||||
"""Processes candidates and selects the final 3 history entries.
|
"""Processes candidates and selects the final 3 history entries.
|
||||||
Only updates final_download_history_entries if new candidates are available.
|
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
|
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'}")
|
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 ):
|
def _update_multipart_toggle_button_text(self):
|
||||||
if hasattr (self ,'multipart_toggle_button'):
|
if hasattr(self, 'multipart_toggle_button'):
|
||||||
if self .allow_multipart_download_setting :
|
if self.allow_multipart_download_setting:
|
||||||
self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON"))
|
scope_text = self.multipart_scope.capitalize()
|
||||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON"))
|
self.multipart_toggle_button.setText(self._tr("multipart_on_button_text", f"Multi-part: {scope_text}"))
|
||||||
else :
|
self.multipart_toggle_button.setToolTip(self._tr("multipart_on_button_tooltip", f"Multipart download is ON. Applied to: {scope_text} files. Click to change."))
|
||||||
self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF"))
|
else:
|
||||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF"))
|
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):
|
def _update_error_button_count(self):
|
||||||
"""Updates the Error button text to show the count of failed files."""
|
"""Updates the Error button text to show the count of failed files."""
|
||||||
@@ -4959,36 +4960,25 @@ class DownloaderApp (QWidget ):
|
|||||||
else:
|
else:
|
||||||
self.error_btn.setText(base_text)
|
self.error_btn.setText(base_text)
|
||||||
|
|
||||||
def _toggle_multipart_mode (self ):
|
def _toggle_multipart_mode(self):
|
||||||
if not self .allow_multipart_download_setting :
|
"""
|
||||||
msg_box =QMessageBox (self )
|
Opens the Multipart Scope Dialog and updates settings based on user choice.
|
||||||
msg_box .setIcon (QMessageBox .Warning )
|
"""
|
||||||
msg_box .setWindowTitle ("Multi-part Download Advisory")
|
current_scope = self.multipart_scope if self.allow_multipart_download_setting else 'both'
|
||||||
msg_box .setText (
|
dialog = MultipartScopeDialog(current_scope, self.multipart_parts_count, self.multipart_min_size_mb, self)
|
||||||
"<b>Multi-part download advisory:</b><br><br>"
|
|
||||||
"<ul>"
|
|
||||||
"<li>Best suited for <b>large files</b> (e.g., single post videos).</li>"
|
|
||||||
"<li>When downloading a full creator feed with many small files (like images):"
|
|
||||||
"<ul><li>May not offer significant speed benefits.</li>"
|
|
||||||
"<li>Could potentially make the UI feel <b>choppy</b>.</li>"
|
|
||||||
"<li>May <b>spam the process log</b> with rapid, numerous small download messages.</li></ul></li>"
|
|
||||||
"<li>Consider using the <b>'Videos' filter</b> if downloading a creator feed to primarily target large files for multi-part.</li>"
|
|
||||||
"</ul><br>"
|
|
||||||
"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 :
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
self .log_signal .emit ("ℹ️ Multi-part download enabling cancelled by user.")
|
self.multipart_scope = dialog.get_selected_scope()
|
||||||
return
|
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 .allow_multipart_download_setting =not self .allow_multipart_download_setting
|
self._update_multipart_toggle_button_text()
|
||||||
self ._update_multipart_toggle_button_text ()
|
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
|
||||||
self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting )
|
|
||||||
self .log_signal .emit (f"ℹ️ Multi-part download set to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}")
|
|
||||||
|
|
||||||
def _open_known_txt_file (self ):
|
def _open_known_txt_file (self ):
|
||||||
if not os .path .exists (self .config_file ):
|
if not os .path .exists (self .config_file ):
|
||||||
|
|||||||
Reference in New Issue
Block a user