mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
3 Commits
v6.3.0
...
d5112a25ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5112a25ee | ||
|
|
791ce503ff | ||
|
|
e5b519d5ce |
@@ -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,
|
||||||
@@ -215,6 +237,11 @@ class PostProcessorWorker:
|
|||||||
if self.check_cancel() or (skip_event and skip_event.is_set()):
|
if self.check_cancel() or (skip_event and skip_event.is_set()):
|
||||||
return 0, 1, "", False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
return 0, 1, "", False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
file_url = file_info.get('url')
|
file_url = file_info.get('url')
|
||||||
cookies_to_use_for_file = None
|
cookies_to_use_for_file = None
|
||||||
if self.use_cookie:
|
if self.use_cookie:
|
||||||
@@ -233,34 +260,28 @@ class PostProcessorWorker:
|
|||||||
self.logger(f" -> Skip File (Keyword in Original Name '{skip_word}'): '{api_original_filename}'. Scope: {self.skip_words_scope}")
|
self.logger(f" -> Skip File (Keyword in Original Name '{skip_word}'): '{api_original_filename}'. Scope: {self.skip_words_scope}")
|
||||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||||
|
|
||||||
cleaned_original_api_filename = clean_filename(api_original_filename)
|
cleaned_original_api_filename = robust_clean_name(api_original_filename)
|
||||||
original_filename_cleaned_base, original_ext = os.path.splitext(cleaned_original_api_filename)
|
original_filename_cleaned_base, original_ext = os.path.splitext(cleaned_original_api_filename)
|
||||||
if not original_ext.startswith('.'): original_ext = '.' + original_ext if original_ext else ''
|
if not original_ext.startswith('.'): original_ext = '.' + original_ext if original_ext else ''
|
||||||
|
|
||||||
if self.manga_mode_active:
|
if self.manga_mode_active:
|
||||||
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||||
# Get the post's publication or added date
|
|
||||||
published_date_str = self.post.get('published')
|
published_date_str = self.post.get('published')
|
||||||
added_date_str = self.post.get('added')
|
added_date_str = self.post.get('added')
|
||||||
formatted_date_str = "nodate" # Fallback if no date is found
|
formatted_date_str = "nodate"
|
||||||
|
|
||||||
date_to_use_str = published_date_str or added_date_str
|
date_to_use_str = published_date_str or added_date_str
|
||||||
|
|
||||||
if date_to_use_str:
|
if date_to_use_str:
|
||||||
try:
|
try:
|
||||||
# Extract just the YYYY-MM-DD part from the timestamp
|
|
||||||
formatted_date_str = date_to_use_str.split('T')[0]
|
formatted_date_str = date_to_use_str.split('T')[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
|
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
|
||||||
else:
|
else:
|
||||||
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
|
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
|
||||||
|
|
||||||
# Combine the date with the cleaned original filename
|
|
||||||
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
|
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
|
||||||
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}"
|
||||||
@@ -281,7 +302,7 @@ class PostProcessorWorker:
|
|||||||
manga_date_file_counter_ref[0] += 1
|
manga_date_file_counter_ref[0] += 1
|
||||||
base_numbered_name = f"{counter_val_for_filename:03d}"
|
base_numbered_name = f"{counter_val_for_filename:03d}"
|
||||||
if self.manga_date_prefix and self.manga_date_prefix.strip():
|
if self.manga_date_prefix and self.manga_date_prefix.strip():
|
||||||
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
|
cleaned_prefix = robust_clean_name(self.manga_date_prefix.strip())
|
||||||
if cleaned_prefix:
|
if cleaned_prefix:
|
||||||
filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}"
|
filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}"
|
||||||
else:
|
else:
|
||||||
@@ -298,7 +319,7 @@ class PostProcessorWorker:
|
|||||||
with counter_lock:
|
with counter_lock:
|
||||||
counter_val_for_filename = manga_global_file_counter_ref[0]
|
counter_val_for_filename = manga_global_file_counter_ref[0]
|
||||||
manga_global_file_counter_ref[0] += 1
|
manga_global_file_counter_ref[0] += 1
|
||||||
cleaned_post_title_base_for_global = clean_filename(post_title.strip() if post_title and post_title.strip() else "post")
|
cleaned_post_title_base_for_global = robust_clean_name(post_title.strip() if post_title and post_title.strip() else "post")
|
||||||
filename_to_save_in_main_path = f"{cleaned_post_title_base_for_global}_{counter_val_for_filename:03d}{original_ext}"
|
filename_to_save_in_main_path = f"{cleaned_post_title_base_for_global}_{counter_val_for_filename:03d}{original_ext}"
|
||||||
else:
|
else:
|
||||||
self.logger(f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_global_file_counter_ref}")
|
self.logger(f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_global_file_counter_ref}")
|
||||||
@@ -330,8 +351,8 @@ 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_folder"):
|
||||||
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"
|
||||||
else:
|
else:
|
||||||
@@ -414,8 +435,7 @@ class PostProcessorWorker:
|
|||||||
final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path)
|
final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path)
|
||||||
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
|
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
|
||||||
with requests.head(file_url, headers=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))
|
||||||
|
|
||||||
@@ -423,26 +443,21 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
if expected_size != -1 and actual_size == expected_size:
|
if expected_size != -1 and actual_size == expected_size:
|
||||||
self.logger(f" -> Skip (File Exists & Complete): '{filename_to_save_in_main_path}' is already on disk with the correct size.")
|
self.logger(f" -> Skip (File Exists & Complete): '{filename_to_save_in_main_path}' is already on disk with the correct size.")
|
||||||
|
|
||||||
# We still need to add its hash to the session to prevent duplicates in other modes
|
|
||||||
# This is a quick hash calculation for the already existing file
|
|
||||||
try:
|
try:
|
||||||
md5_hasher = hashlib.md5()
|
md5_hasher = hashlib.md5()
|
||||||
with open(final_save_path_check, 'rb') as f_verify:
|
with open(final_save_path_check, 'rb') as f_verify:
|
||||||
for chunk in iter(lambda: f_verify.read(8192), b""):
|
for chunk in iter(lambda: f_verify.read(8192), b""):
|
||||||
md5_hasher.update(chunk)
|
md5_hasher.update(chunk)
|
||||||
|
|
||||||
with self.downloaded_hash_counts_lock:
|
with self.downloaded_hash_counts_lock:
|
||||||
self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1
|
self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1
|
||||||
except Exception as hash_exc:
|
except Exception as hash_exc:
|
||||||
self.logger(f" ⚠️ Could not hash existing file '{filename_to_save_in_main_path}' for session: {hash_exc}")
|
self.logger(f" ⚠️ Could not hash existing file '{filename_to_save_in_main_path}' for session: {hash_exc}")
|
||||||
|
|
||||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||||
else:
|
else:
|
||||||
self.logger(f" ⚠️ File '{filename_to_save_in_main_path}' exists but is incomplete (Expected: {expected_size}, Actual: {actual_size}). Re-downloading.")
|
self.logger(f" ⚠️ File '{filename_to_save_in_main_path}' exists but is incomplete (Expected: {expected_size}, Actual: {actual_size}). Re-downloading.")
|
||||||
|
|
||||||
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.")
|
||||||
|
|
||||||
retry_delay = 5
|
retry_delay = 5
|
||||||
downloaded_size_bytes = 0
|
downloaded_size_bytes = 0
|
||||||
calculated_file_hash = None
|
calculated_file_hash = None
|
||||||
@@ -463,13 +478,30 @@ 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)
|
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 +510,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
|
||||||
@@ -487,7 +519,7 @@ class PostProcessorWorker:
|
|||||||
download_successful_flag = True
|
download_successful_flag = True
|
||||||
downloaded_size_bytes = mp_bytes
|
downloaded_size_bytes = mp_bytes
|
||||||
calculated_file_hash = mp_hash
|
calculated_file_hash = mp_hash
|
||||||
downloaded_part_file_path = mp_save_path_for_unique_part_stem_arg + ".part"
|
downloaded_part_file_path = mp_save_path_for_unique_part_stem_arg
|
||||||
if mp_file_handle: mp_file_handle.close()
|
if mp_file_handle: mp_file_handle.close()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -553,12 +585,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
|
||||||
@@ -635,25 +670,21 @@ class PostProcessorWorker:
|
|||||||
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
|
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
|
||||||
try:
|
try:
|
||||||
with Image.open(downloaded_part_file_path) as img:
|
with Image.open(downloaded_part_file_path) as img:
|
||||||
# Convert to RGB to avoid issues with paletted images or alpha channels in WebP
|
|
||||||
if img.mode not in ('RGB', 'RGBA'):
|
if img.mode not in ('RGB', 'RGBA'):
|
||||||
img = img.convert('RGBA')
|
img = img.convert('RGBA')
|
||||||
|
|
||||||
# Use an in-memory buffer to save the compressed image
|
|
||||||
output_buffer = BytesIO()
|
output_buffer = BytesIO()
|
||||||
img.save(output_buffer, format='WebP', quality=85)
|
img.save(output_buffer, format='WebP', quality=85)
|
||||||
|
|
||||||
# This buffer now holds the compressed data
|
|
||||||
data_to_write_io = output_buffer
|
data_to_write_io = output_buffer
|
||||||
|
|
||||||
# Update the filename to use the .webp extension
|
|
||||||
base, _ = os.path.splitext(filename_to_save_in_main_path)
|
base, _ = os.path.splitext(filename_to_save_in_main_path)
|
||||||
filename_to_save_in_main_path = f"{base}.webp"
|
filename_to_save_in_main_path = f"{base}.webp"
|
||||||
self.logger(f" ✅ Compression successful. New size: {len(data_to_write_io.getvalue()) / (1024*1024):.2f} MB")
|
self.logger(f" ✅ Compression successful. New size: {len(data_to_write_io.getvalue()) / (1024*1024):.2f} MB")
|
||||||
|
|
||||||
except Exception as e_compress:
|
except Exception as e_compress:
|
||||||
self.logger(f" ⚠️ Failed to compress '{api_original_filename}': {e_compress}. Saving original file instead.")
|
self.logger(f" ⚠️ Failed to compress '{api_original_filename}': {e_compress}. Saving original file instead.")
|
||||||
data_to_write_io = None # Ensure we fall back to saving the original
|
data_to_write_io = None
|
||||||
|
|
||||||
effective_save_folder = target_folder_path
|
effective_save_folder = target_folder_path
|
||||||
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
|
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
|
||||||
@@ -671,17 +702,14 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if data_to_write_io:
|
if data_to_write_io:
|
||||||
# Write the compressed data from the in-memory buffer
|
|
||||||
with open(final_save_path, 'wb') as f_out:
|
with open(final_save_path, 'wb') as f_out:
|
||||||
f_out.write(data_to_write_io.getvalue())
|
f_out.write(data_to_write_io.getvalue())
|
||||||
# Clean up the original downloaded part file
|
|
||||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||||
try:
|
try:
|
||||||
os.remove(downloaded_part_file_path)
|
os.remove(downloaded_part_file_path)
|
||||||
except OSError as e_rem:
|
except OSError as e_rem:
|
||||||
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
|
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
|
||||||
else:
|
else:
|
||||||
# No compression was done, just rename the original file
|
|
||||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
os.rename(downloaded_part_file_path, final_save_path)
|
os.rename(downloaded_part_file_path, final_save_path)
|
||||||
@@ -728,7 +756,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 +770,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 +1072,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 +1654,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 +1811,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 +1875,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 +2018,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,50 +2961,55 @@ 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)
|
||||||
|
|
||||||
|
if self.active_update_profile:
|
||||||
|
self.log_signal.emit(" Update session active: Loading existing processed post IDs to find new content.")
|
||||||
|
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||||
|
|
||||||
|
elif not is_restore:
|
||||||
|
self.log_signal.emit(" Fresh download session: Clearing previous post history for this creator to re-download all.")
|
||||||
|
if 'processed_post_ids' in creator_profile_data:
|
||||||
|
creator_profile_data['processed_post_ids'] = []
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
if self.active_update_profile:
|
|
||||||
self.log_signal.emit(" Update session active: Loading existing processed post IDs to find new content.")
|
|
||||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
|
||||||
|
|
||||||
elif not is_restore:
|
|
||||||
self.log_signal.emit(" Fresh download session: Clearing previous post history for this creator to re-download all.")
|
|
||||||
if 'processed_post_ids' in creator_profile_data:
|
|
||||||
creator_profile_data['processed_post_ids'] = []
|
|
||||||
|
|
||||||
session_processed_ids = set(processed_post_ids_for_restore)
|
session_processed_ids = set(processed_post_ids_for_restore)
|
||||||
combined_processed_ids = session_processed_ids.union(profile_processed_ids)
|
combined_processed_ids = session_processed_ids.union(profile_processed_ids)
|
||||||
@@ -3453,6 +3453,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 +3500,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 +3517,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 +3963,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 +3971,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 +3983,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.
|
||||||
@@ -4553,42 +4555,49 @@ class DownloaderApp (QWidget ):
|
|||||||
self .active_retry_futures_map [future ]=job_details
|
self .active_retry_futures_map [future ]=job_details
|
||||||
self .active_retry_futures .append (future )
|
self .active_retry_futures .append (future )
|
||||||
|
|
||||||
def _execute_single_file_retry (self ,job_details ,common_args ):
|
def _execute_single_file_retry(self, job_details, common_args):
|
||||||
"""Executes a single file download retry attempt."""
|
"""
|
||||||
dummy_post_data ={'id':job_details ['original_post_id_for_log'],'title':job_details ['post_title']}
|
Executes a single file download retry attempt. This function is called by the retry thread pool.
|
||||||
|
"""
|
||||||
|
# This worker is temporary and only for this retry task.
|
||||||
|
# It needs dummy post data to initialize.
|
||||||
|
dummy_post_data = {'id': job_details['original_post_id_for_log'], 'title': job_details['post_title']}
|
||||||
|
|
||||||
ppw_init_args ={
|
# Reconstruct the post_page_url, which is needed by the download function
|
||||||
**common_args ,
|
service = job_details.get('service', 'unknown_service')
|
||||||
'post_data':dummy_post_data ,
|
user_id = job_details.get('user_id', 'unknown_user')
|
||||||
'service':job_details .get ('service','unknown_service'),
|
post_id = job_details.get('original_post_id_for_log', 'unknown_id')
|
||||||
'user_id':job_details .get ('user_id','unknown_user'),
|
api_url_input = job_details.get('api_url_input', '')
|
||||||
'api_url_input':job_details .get ('api_url_input',''),
|
parsed_api_url = urlparse(api_url_input)
|
||||||
'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ),
|
api_domain = parsed_api_url.netloc if parsed_api_url.netloc else self._get_domain_for_service(service)
|
||||||
'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ),
|
post_page_url = f"https://{api_domain}/{service}/user/{user_id}/post/{post_id}"
|
||||||
'scan_content_for_images':common_args .get ('scan_content_for_images',False ),
|
|
||||||
'use_cookie':common_args .get ('use_cookie',False ),
|
# Prepare all arguments for the PostProcessorWorker
|
||||||
'cookie_text':common_args .get ('cookie_text',""),
|
ppw_init_args = {
|
||||||
'selected_cookie_file':common_args .get ('selected_cookie_file',None ),
|
**common_args,
|
||||||
'app_base_dir':common_args .get ('app_base_dir',None ),
|
'post_data': dummy_post_data,
|
||||||
|
'service': service,
|
||||||
|
'user_id': user_id,
|
||||||
|
'api_url_input': api_url_input
|
||||||
}
|
}
|
||||||
worker =PostProcessorWorker (**ppw_init_args )
|
|
||||||
|
|
||||||
dl_count ,skip_count ,filename_saved ,original_kept ,status ,_ =worker ._download_single_file (
|
worker = PostProcessorWorker(**ppw_init_args)
|
||||||
file_info =job_details ['file_info'],
|
|
||||||
target_folder_path =job_details ['target_folder_path'],
|
# Call the download method with the corrected arguments
|
||||||
headers =job_details ['headers'],
|
dl_count, skip_count, filename_saved, original_kept, status, _ = worker._download_single_file(
|
||||||
original_post_id_for_log =job_details ['original_post_id_for_log'],
|
file_info=job_details['file_info'],
|
||||||
skip_event =None ,
|
target_folder_path=job_details['target_folder_path'],
|
||||||
post_title =job_details ['post_title'],
|
post_page_url=post_page_url, # Using the correct argument
|
||||||
file_index_in_post =job_details ['file_index_in_post'],
|
original_post_id_for_log=job_details['original_post_id_for_log'],
|
||||||
num_files_in_this_post =job_details ['num_files_in_this_post'],
|
skip_event=None,
|
||||||
forced_filename_override =job_details .get ('forced_filename_override')
|
post_title=job_details['post_title'],
|
||||||
|
file_index_in_post=job_details['file_index_in_post'],
|
||||||
|
num_files_in_this_post=job_details['num_files_in_this_post'],
|
||||||
|
forced_filename_override=job_details.get('forced_filename_override')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_successful_download = (status == FILE_DOWNLOAD_STATUS_SUCCESS)
|
||||||
|
is_resolved_as_skipped = (status == FILE_DOWNLOAD_STATUS_SKIPPED)
|
||||||
is_successful_download =(status ==FILE_DOWNLOAD_STATUS_SUCCESS )
|
|
||||||
is_resolved_as_skipped =(status ==FILE_DOWNLOAD_STATUS_SKIPPED )
|
|
||||||
|
|
||||||
return is_successful_download or is_resolved_as_skipped
|
return is_successful_download or is_resolved_as_skipped
|
||||||
|
|
||||||
@@ -4937,14 +4946,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 +4969,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