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 *
|
||||
|
||||
def robust_clean_name(name):
|
||||
"""A more robust function to remove illegal characters for filenames and folders."""
|
||||
if not name:
|
||||
return ""
|
||||
# Removes illegal characters for Windows, macOS, and Linux: < > : " / \ | ? *
|
||||
# Also removes control characters (ASCII 0-31) which are invisible but invalid.
|
||||
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*]'
|
||||
cleaned_name = re.sub(illegal_chars_pattern, '', name)
|
||||
|
||||
# Remove leading/trailing spaces or periods, which can cause issues.
|
||||
cleaned_name = cleaned_name.strip(' .')
|
||||
|
||||
# If the name is empty after cleaning (e.g., it was only illegal chars),
|
||||
# provide a safe fallback name.
|
||||
if not cleaned_name:
|
||||
return "untitled_folder" # Or "untitled_file" depending on context
|
||||
return cleaned_name
|
||||
|
||||
class PostProcessorSignals (QObject ):
|
||||
progress_signal =pyqtSignal (str )
|
||||
file_download_status_signal =pyqtSignal (bool )
|
||||
@@ -64,7 +82,6 @@ class PostProcessorSignals (QObject ):
|
||||
worker_finished_signal = pyqtSignal(tuple)
|
||||
|
||||
class PostProcessorWorker:
|
||||
|
||||
def __init__(self, post_data, download_root, known_names,
|
||||
filter_character_list, emitter,
|
||||
unwanted_keywords, filter_mode, skip_zip,
|
||||
@@ -104,7 +121,10 @@ class PostProcessorWorker:
|
||||
text_export_format='txt',
|
||||
single_pdf_mode=False,
|
||||
project_root_dir=None,
|
||||
processed_post_ids=None
|
||||
processed_post_ids=None,
|
||||
multipart_scope='both',
|
||||
multipart_parts_count=4,
|
||||
multipart_min_size_mb=100
|
||||
):
|
||||
self.post = post_data
|
||||
self.download_root = download_root
|
||||
@@ -166,7 +186,9 @@ class PostProcessorWorker:
|
||||
self.single_pdf_mode = single_pdf_mode
|
||||
self.project_root_dir = project_root_dir
|
||||
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else []
|
||||
|
||||
self.multipart_scope = multipart_scope
|
||||
self.multipart_parts_count = multipart_parts_count
|
||||
self.multipart_min_size_mb = multipart_min_size_mb
|
||||
if self.compress_images and Image is None:
|
||||
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
||||
self.compress_images = False
|
||||
@@ -201,7 +223,7 @@ class PostProcessorWorker:
|
||||
return self .dynamic_filter_holder .get_filters ()
|
||||
return self .filter_character_list_objects_initial
|
||||
|
||||
def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event,
|
||||
def _download_single_file(self, file_info, target_folder_path, post_page_url, original_post_id_for_log, skip_event,
|
||||
post_title="", file_index_in_post=0, num_files_in_this_post=1,
|
||||
manga_date_file_counter_ref=None,
|
||||
forced_filename_override=None,
|
||||
@@ -215,6 +237,11 @@ class PostProcessorWorker:
|
||||
if self.check_cancel() or (skip_event and skip_event.is_set()):
|
||||
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')
|
||||
cookies_to_use_for_file = None
|
||||
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}")
|
||||
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)
|
||||
if not original_ext.startswith('.'): original_ext = '.' + original_ext if original_ext else ''
|
||||
|
||||
if self.manga_mode_active:
|
||||
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||
# Get the post's publication or added date
|
||||
published_date_str = self.post.get('published')
|
||||
added_date_str = self.post.get('added')
|
||||
formatted_date_str = "nodate" # Fallback if no date is found
|
||||
|
||||
formatted_date_str = "nodate"
|
||||
date_to_use_str = published_date_str or added_date_str
|
||||
|
||||
if date_to_use_str:
|
||||
try:
|
||||
# Extract just the YYYY-MM-DD part from the timestamp
|
||||
formatted_date_str = date_to_use_str.split('T')[0]
|
||||
except Exception:
|
||||
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
|
||||
else:
|
||||
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
|
||||
|
||||
# Combine the date with the cleaned original filename
|
||||
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
|
||||
was_original_name_kept_flag = True
|
||||
elif self.manga_filename_style == STYLE_POST_TITLE:
|
||||
if post_title and post_title.strip():
|
||||
cleaned_post_title_base = clean_filename(post_title.strip())
|
||||
cleaned_post_title_base = robust_clean_name(post_title.strip())
|
||||
if num_files_in_this_post > 1:
|
||||
if file_index_in_post == 0:
|
||||
filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}"
|
||||
@@ -281,7 +302,7 @@ class PostProcessorWorker:
|
||||
manga_date_file_counter_ref[0] += 1
|
||||
base_numbered_name = f"{counter_val_for_filename:03d}"
|
||||
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:
|
||||
filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}"
|
||||
else:
|
||||
@@ -298,7 +319,7 @@ class PostProcessorWorker:
|
||||
with counter_lock:
|
||||
counter_val_for_filename = manga_global_file_counter_ref[0]
|
||||
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}"
|
||||
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}")
|
||||
@@ -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'.")
|
||||
|
||||
if post_title and post_title.strip():
|
||||
temp_cleaned_title = clean_filename(post_title.strip())
|
||||
if not temp_cleaned_title or temp_cleaned_title.startswith("untitled_file"):
|
||||
temp_cleaned_title = robust_clean_name(post_title.strip())
|
||||
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.")
|
||||
cleaned_post_title_for_filename = "post"
|
||||
else:
|
||||
@@ -414,8 +435,7 @@ class PostProcessorWorker:
|
||||
final_save_path_check = os.path.join(target_folder_path, filename_to_save_in_main_path)
|
||||
if os.path.exists(final_save_path_check):
|
||||
try:
|
||||
# Use a HEAD request to get the expected size without downloading the body
|
||||
with requests.head(file_url, headers=headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
|
||||
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
|
||||
head_response.raise_for_status()
|
||||
expected_size = int(head_response.headers.get('Content-Length', -1))
|
||||
|
||||
@@ -423,26 +443,21 @@ class PostProcessorWorker:
|
||||
|
||||
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.")
|
||||
|
||||
# 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:
|
||||
md5_hasher = hashlib.md5()
|
||||
with open(final_save_path_check, 'rb') as f_verify:
|
||||
for chunk in iter(lambda: f_verify.read(8192), b""):
|
||||
md5_hasher.update(chunk)
|
||||
|
||||
with self.downloaded_hash_counts_lock:
|
||||
self.downloaded_hash_counts[md5_hasher.hexdigest()] += 1
|
||||
except Exception as 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
|
||||
else:
|
||||
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:
|
||||
self.logger(f" ⚠️ Could not verify size of existing file '{filename_to_save_in_main_path}': {e}. Proceeding with download.")
|
||||
|
||||
retry_delay = 5
|
||||
downloaded_size_bytes = 0
|
||||
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})...")
|
||||
time.sleep(retry_delay * (2 ** (attempt_num_single_stream - 1)))
|
||||
self._emit_signal('file_download_status', True)
|
||||
response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
response = requests.get(file_url, headers=file_download_headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
|
||||
response.raise_for_status()
|
||||
total_size_bytes = int(response.headers.get('Content-Length', 0))
|
||||
num_parts_for_file = min(self.num_file_threads, MAX_PARTS_FOR_MULTIPART_DOWNLOAD)
|
||||
num_parts_for_file = min(self.multipart_parts_count, MAX_PARTS_FOR_MULTIPART_DOWNLOAD)
|
||||
|
||||
file_is_eligible_by_scope = False
|
||||
if self.multipart_scope == 'videos':
|
||||
if is_video(api_original_filename):
|
||||
file_is_eligible_by_scope = True
|
||||
elif self.multipart_scope == 'archives':
|
||||
if is_archive(api_original_filename):
|
||||
file_is_eligible_by_scope = True
|
||||
elif self.multipart_scope == 'both':
|
||||
if is_video(api_original_filename) or is_archive(api_original_filename):
|
||||
file_is_eligible_by_scope = True
|
||||
|
||||
min_size_in_bytes = self.multipart_min_size_mb * 1024 * 1024
|
||||
|
||||
attempt_multipart = (self.allow_multipart_download and MULTIPART_DOWNLOADER_AVAILABLE and
|
||||
num_parts_for_file > 1 and total_size_bytes > MIN_SIZE_FOR_MULTIPART_DOWNLOAD and
|
||||
file_is_eligible_by_scope and
|
||||
num_parts_for_file > 1 and total_size_bytes > min_size_in_bytes and
|
||||
'bytes' in response.headers.get('Accept-Ranges', '').lower())
|
||||
|
||||
if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break
|
||||
|
||||
if attempt_multipart:
|
||||
@@ -478,7 +510,7 @@ class PostProcessorWorker:
|
||||
response_for_this_attempt = None
|
||||
mp_save_path_for_unique_part_stem_arg = os.path.join(target_folder_path, f"{unique_part_file_stem_on_disk}{temp_file_ext_for_unique_part}")
|
||||
mp_success, mp_bytes, mp_hash, mp_file_handle = download_file_in_parts(
|
||||
file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, headers, api_original_filename,
|
||||
file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, file_download_headers, api_original_filename,
|
||||
emitter_for_multipart=self.emitter, cookies_for_chunk_session=cookies_to_use_for_file,
|
||||
cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger,
|
||||
pause_event=self.pause_event
|
||||
@@ -487,7 +519,7 @@ class PostProcessorWorker:
|
||||
download_successful_flag = True
|
||||
downloaded_size_bytes = mp_bytes
|
||||
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()
|
||||
break
|
||||
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)):
|
||||
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
|
||||
last_exception_for_retry_later = e
|
||||
is_permanent_error = True
|
||||
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
|
||||
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
|
||||
break
|
||||
if e.response is not None and e.response.status_code == 403:
|
||||
self.logger(f" ⚠️ Download Error (403 Forbidden): {api_original_filename}. This often requires valid cookies.")
|
||||
self.logger(f" Will retry... Check your 'Use Cookie' settings if this persists.")
|
||||
last_exception_for_retry_later = e
|
||||
else:
|
||||
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
|
||||
last_exception_for_retry_later = e
|
||||
is_permanent_error = True
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
|
||||
last_exception_for_retry_later = e
|
||||
@@ -635,26 +670,22 @@ class PostProcessorWorker:
|
||||
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
|
||||
try:
|
||||
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'):
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# Use an in-memory buffer to save the compressed image
|
||||
output_buffer = BytesIO()
|
||||
img.save(output_buffer, format='WebP', quality=85)
|
||||
|
||||
# This buffer now holds the compressed data
|
||||
data_to_write_io = output_buffer
|
||||
|
||||
# Update the filename to use the .webp extension
|
||||
base, _ = os.path.splitext(filename_to_save_in_main_path)
|
||||
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")
|
||||
|
||||
except Exception as e_compress:
|
||||
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
|
||||
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
|
||||
counter = 1
|
||||
@@ -671,17 +702,14 @@ class PostProcessorWorker:
|
||||
|
||||
try:
|
||||
if data_to_write_io:
|
||||
# Write the compressed data from the in-memory buffer
|
||||
with open(final_save_path, 'wb') as f_out:
|
||||
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):
|
||||
try:
|
||||
os.remove(downloaded_part_file_path)
|
||||
except OSError as e_rem:
|
||||
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
|
||||
else:
|
||||
# No compression was done, just rename the original file
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
time.sleep(0.1)
|
||||
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}")
|
||||
|
||||
permanent_failure_details = {
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': file_download_headers,
|
||||
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post,
|
||||
'forced_filename_override': filename_to_save_in_main_path,
|
||||
@@ -742,7 +770,7 @@ class PostProcessorWorker:
|
||||
details_for_failure = {
|
||||
'file_info': file_info,
|
||||
'target_folder_path': target_folder_path,
|
||||
'headers': headers,
|
||||
'headers': file_download_headers,
|
||||
'original_post_id_for_log': original_post_id_for_log,
|
||||
'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post,
|
||||
@@ -1044,7 +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])
|
||||
|
||||
if not self.extract_links_only and self.use_post_subfolders:
|
||||
cleaned_post_title_for_sub = clean_folder_name(post_title)
|
||||
cleaned_post_title_for_sub = robust_clean_name(post_title)
|
||||
post_id_for_fallback = self.post.get('id', 'unknown_id')
|
||||
|
||||
if not cleaned_post_title_for_sub or cleaned_post_title_for_sub == "untitled_folder":
|
||||
@@ -1626,7 +1654,7 @@ class PostProcessorWorker:
|
||||
self._download_single_file,
|
||||
file_info=file_info_to_dl,
|
||||
target_folder_path=current_path_for_file_instance,
|
||||
headers=headers, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag,
|
||||
post_page_url=post_page_url, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag,
|
||||
post_title=post_title, manga_date_file_counter_ref=manga_date_counter_to_pass,
|
||||
manga_global_file_counter_ref=manga_global_counter_to_pass, folder_context_name_for_history=folder_context_for_file,
|
||||
file_index_in_post=file_idx, num_files_in_this_post=len(files_to_download_info_list)
|
||||
@@ -1783,6 +1811,8 @@ class DownloadThread(QThread):
|
||||
remove_from_filename_words_list=None,
|
||||
manga_date_prefix='',
|
||||
allow_multipart_download=True,
|
||||
multipart_parts_count=4,
|
||||
multipart_min_size_mb=100,
|
||||
selected_cookie_file=None,
|
||||
override_output_dir=None,
|
||||
app_base_dir=None,
|
||||
@@ -1845,6 +1875,8 @@ class DownloadThread(QThread):
|
||||
self.remove_from_filename_words_list = remove_from_filename_words_list
|
||||
self.manga_date_prefix = manga_date_prefix
|
||||
self.allow_multipart_download = allow_multipart_download
|
||||
self.multipart_parts_count = multipart_parts_count
|
||||
self.multipart_min_size_mb = multipart_min_size_mb
|
||||
self.selected_cookie_file = selected_cookie_file
|
||||
self.app_base_dir = app_base_dir
|
||||
self.cookie_text = cookie_text
|
||||
@@ -1986,6 +2018,8 @@ class DownloadThread(QThread):
|
||||
'text_only_scope': self.text_only_scope,
|
||||
'text_export_format': self.text_export_format,
|
||||
'single_pdf_mode': self.single_pdf_mode,
|
||||
'multipart_parts_count': self.multipart_parts_count,
|
||||
'multipart_min_size_mb': self.multipart_min_size_mb,
|
||||
'project_root_dir': self.project_root_dir,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
from fpdf import FPDF
|
||||
FPDF_AVAILABLE = True
|
||||
|
||||
# --- FIX: Move the class definition inside the try block ---
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def header(self):
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
if self.font_family:
|
||||
self.set_font(self.font_family, '', 8)
|
||||
else:
|
||||
self.set_font('Arial', '', 8)
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
except ImportError:
|
||||
FPDF_AVAILABLE = False
|
||||
# If the import fails, FPDF and PDF will not be defined,
|
||||
# but the program won't crash here.
|
||||
FPDF = None
|
||||
PDF = None
|
||||
|
||||
def strip_html_tags(text):
|
||||
if not text:
|
||||
@@ -12,19 +31,6 @@ def strip_html_tags(text):
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def header(self):
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
if self.font_family:
|
||||
self.set_font(self.font_family, '', 8)
|
||||
else:
|
||||
self.set_font('Arial', '', 8)
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
||||
"""
|
||||
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
|
||||
@@ -68,7 +74,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
if 'comments' in post and post['comments']:
|
||||
@@ -89,7 +95,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
pdf.multi_cell(w=0, h=7, txt=body)
|
||||
|
||||
if comment_index < len(comments_list) - 1:
|
||||
pdf.ln(3)
|
||||
@@ -97,7 +103,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.ln(3)
|
||||
elif 'content' in post:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
@@ -105,4 +111,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -58,6 +58,7 @@ from .dialogs.MoreOptionsDialog import MoreOptionsDialog
|
||||
from .dialogs.SinglePDF import create_single_pdf_from_content
|
||||
from .dialogs.SupportDialog import SupportDialog
|
||||
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
|
||||
from .dialogs.MultipartScopeDialog import MultipartScopeDialog
|
||||
|
||||
class DynamicFilterHolder:
|
||||
"""A thread-safe class to hold and update character filters during a download."""
|
||||
@@ -222,6 +223,9 @@ class DownloaderApp (QWidget ):
|
||||
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||
self.mega_download_log_preserved_once = False
|
||||
self.allow_multipart_download_setting = False
|
||||
self.multipart_scope = 'both'
|
||||
self.multipart_parts_count = 4
|
||||
self.multipart_min_size_mb = 100
|
||||
self.use_cookie_setting = False
|
||||
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool)
|
||||
self.cookie_text_setting = ""
|
||||
@@ -236,7 +240,8 @@ class DownloaderApp (QWidget ):
|
||||
self.session_temp_files = []
|
||||
self.single_pdf_mode = False
|
||||
self.save_creator_json_enabled_this_session = True
|
||||
|
||||
self.is_single_post_session = False
|
||||
|
||||
print(f"ℹ️ Known.txt will be loaded/saved at: {self.config_file}")
|
||||
|
||||
try:
|
||||
@@ -267,7 +272,7 @@ class DownloaderApp (QWidget ):
|
||||
self.download_location_label_widget = None
|
||||
self.remove_from_filename_label_widget = None
|
||||
self.skip_words_label_widget = None
|
||||
self.setWindowTitle("Kemono Downloader v6.2.1")
|
||||
self.setWindowTitle("Kemono Downloader v6.3.0")
|
||||
setup_ui(self)
|
||||
self._connect_signals()
|
||||
self.log_signal.emit("ℹ️ Local API server functionality has been removed.")
|
||||
@@ -2689,16 +2694,13 @@ class DownloaderApp (QWidget ):
|
||||
url_text =self .link_input .text ().strip ()if self .link_input else ""
|
||||
_ ,_ ,post_id =extract_post_info (url_text )
|
||||
|
||||
# --- START: MODIFIED LOGIC ---
|
||||
is_creator_feed =not post_id if url_text else False
|
||||
is_single_post = bool(post_id)
|
||||
is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
|
||||
|
||||
# If the download queue contains items selected from the popup, treat it as a single-post context for UI purposes.
|
||||
if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue):
|
||||
is_single_post = True
|
||||
|
||||
# Allow Manga Mode checkbox for any valid URL (creator or single post) or if single posts are queued.
|
||||
can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on
|
||||
|
||||
if self .manga_mode_checkbox :
|
||||
@@ -2709,12 +2711,10 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
manga_mode_effectively_on = can_enable_manga_checkbox and checked
|
||||
|
||||
# If it's a single post context, prevent sequential styles from being selected as they don't apply.
|
||||
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
if is_single_post and self.manga_filename_style in sequential_styles:
|
||||
self.manga_filename_style = STYLE_POST_TITLE # Default to a safe, non-sequential style
|
||||
self.manga_filename_style = STYLE_POST_TITLE
|
||||
self._update_manga_filename_style_button_text()
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
|
||||
if self .manga_rename_toggle_button :
|
||||
self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
|
||||
@@ -2748,11 +2748,9 @@ class DownloaderApp (QWidget ):
|
||||
self .manga_date_prefix_input .setMaximumWidth (16777215 )
|
||||
self .manga_date_prefix_input .setMinimumWidth (0 )
|
||||
|
||||
if hasattr (self ,'multipart_toggle_button'):
|
||||
|
||||
hide_multipart_button_due_mode =is_only_links_mode or is_only_archives_mode or is_only_audio_mode
|
||||
hide_multipart_button_due_manga_mode =manga_mode_effectively_on
|
||||
self .multipart_toggle_button .setVisible (not (hide_multipart_button_due_mode or hide_multipart_button_due_manga_mode ))
|
||||
if hasattr(self, 'multipart_toggle_button'):
|
||||
hide_multipart_button_due_mode = is_only_links_mode or is_only_archives_mode or is_only_audio_mode
|
||||
self.multipart_toggle_button.setVisible(not hide_multipart_button_due_mode)
|
||||
|
||||
self ._update_multithreading_for_date_mode ()
|
||||
|
||||
@@ -2953,9 +2951,6 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
service, user_id, post_id_from_url = extract_post_info(api_url)
|
||||
|
||||
# --- START: MODIFIED SECTION ---
|
||||
# This check is now smarter. It only triggers the error if the item from the queue
|
||||
# was supposed to be a post ('single_post_from_popup', etc.) but couldn't be parsed.
|
||||
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue:
|
||||
self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}")
|
||||
self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.")
|
||||
@@ -2966,50 +2961,55 @@ class DownloaderApp (QWidget ):
|
||||
kept_original_names_list=[]
|
||||
)
|
||||
return False
|
||||
# --- END: MODIFIED SECTION ---
|
||||
|
||||
if not service or not user_id:
|
||||
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
|
||||
return False
|
||||
|
||||
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||
self.is_single_post_session = bool(post_id_from_url)
|
||||
|
||||
creator_profile_data = {}
|
||||
if self.save_creator_json_enabled_this_session:
|
||||
creator_name_for_profile = None
|
||||
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
|
||||
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
|
||||
else:
|
||||
creator_key = (service.lower(), str(user_id))
|
||||
creator_name_for_profile = self.creator_name_cache.get(creator_key)
|
||||
if not self.is_single_post_session:
|
||||
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
|
||||
|
||||
if not creator_name_for_profile:
|
||||
creator_name_for_profile = f"{service}_{user_id}"
|
||||
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
|
||||
creator_profile_data = {}
|
||||
if self.save_creator_json_enabled_this_session:
|
||||
creator_name_for_profile = None
|
||||
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
|
||||
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
|
||||
else:
|
||||
creator_key = (service.lower(), str(user_id))
|
||||
creator_name_for_profile = self.creator_name_cache.get(creator_key)
|
||||
|
||||
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
|
||||
|
||||
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
|
||||
creator_profile_data['settings'] = current_settings
|
||||
|
||||
creator_profile_data.setdefault('creator_url', [])
|
||||
if api_url not in creator_profile_data['creator_url']:
|
||||
creator_profile_data['creator_url'].append(api_url)
|
||||
if not creator_name_for_profile:
|
||||
creator_name_for_profile = f"{service}_{user_id}"
|
||||
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
|
||||
|
||||
creator_profile_data.setdefault('processed_post_ids', [])
|
||||
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
|
||||
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
|
||||
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
|
||||
|
||||
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
|
||||
creator_profile_data['settings'] = current_settings
|
||||
|
||||
creator_profile_data.setdefault('creator_url', [])
|
||||
if api_url not in creator_profile_data['creator_url']:
|
||||
creator_profile_data['creator_url'].append(api_url)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
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,
|
||||
'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread,
|
||||
'allow_multipart_download': allow_multipart,
|
||||
'multipart_scope': self.multipart_scope,
|
||||
'multipart_parts_count': self.multipart_parts_count,
|
||||
'multipart_min_size_mb': self.multipart_min_size_mb,
|
||||
'cookie_text': cookie_text_from_input,
|
||||
'selected_cookie_file': selected_cookie_file_path_for_backend,
|
||||
'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread,
|
||||
@@ -3497,7 +3500,7 @@ class DownloaderApp (QWidget ):
|
||||
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'scan_content_for_images',
|
||||
'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', 'override_output_dir', 'project_root_dir',
|
||||
'text_only_scope', 'text_export_format',
|
||||
'single_pdf_mode',
|
||||
'single_pdf_mode','multipart_parts_count', 'multipart_min_size_mb',
|
||||
'use_date_prefix_for_subfolder','keep_in_post_duplicates', 'keep_duplicates_mode',
|
||||
'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock',
|
||||
'processed_post_ids'
|
||||
@@ -3514,7 +3517,6 @@ class DownloaderApp (QWidget ):
|
||||
self.is_paused = False
|
||||
return True
|
||||
|
||||
|
||||
def restore_download(self):
|
||||
"""Initiates the download restoration process."""
|
||||
if self._is_download_active():
|
||||
@@ -3960,8 +3962,8 @@ class DownloaderApp (QWidget ):
|
||||
self.log_signal.emit("="*40)
|
||||
|
||||
def _add_to_history_candidates(self, history_data):
|
||||
"""Adds processed post data to the history candidates list and updates the creator profile."""
|
||||
if self.save_creator_json_enabled_this_session:
|
||||
"""Adds processed post data to the history candidates list and updates the creator profile."""
|
||||
if self.save_creator_json_enabled_this_session and not self.is_single_post_session:
|
||||
post_id = history_data.get('post_id')
|
||||
service = history_data.get('service')
|
||||
user_id = history_data.get('user_id')
|
||||
@@ -3969,7 +3971,6 @@ class DownloaderApp (QWidget ):
|
||||
creator_key = (service.lower(), str(user_id))
|
||||
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{user_id}")
|
||||
|
||||
# Load the profile data before using it to prevent NameError
|
||||
profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
|
||||
|
||||
if post_id not in profile_data.get('processed_post_ids', []):
|
||||
@@ -3982,6 +3983,7 @@ class DownloaderApp (QWidget ):
|
||||
history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown'))
|
||||
self.download_history_candidates.append(history_data)
|
||||
|
||||
|
||||
def _finalize_download_history (self ):
|
||||
"""Processes candidates and selects the final 3 history entries.
|
||||
Only updates final_download_history_entries if new candidates are available.
|
||||
@@ -4553,44 +4555,51 @@ class DownloaderApp (QWidget ):
|
||||
self .active_retry_futures_map [future ]=job_details
|
||||
self .active_retry_futures .append (future )
|
||||
|
||||
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']}
|
||||
def _execute_single_file_retry(self, job_details, common_args):
|
||||
"""
|
||||
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 ={
|
||||
**common_args ,
|
||||
'post_data':dummy_post_data ,
|
||||
'service':job_details .get ('service','unknown_service'),
|
||||
'user_id':job_details .get ('user_id','unknown_user'),
|
||||
'api_url_input':job_details .get ('api_url_input',''),
|
||||
'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ),
|
||||
'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ),
|
||||
'scan_content_for_images':common_args .get ('scan_content_for_images',False ),
|
||||
'use_cookie':common_args .get ('use_cookie',False ),
|
||||
'cookie_text':common_args .get ('cookie_text',""),
|
||||
'selected_cookie_file':common_args .get ('selected_cookie_file',None ),
|
||||
'app_base_dir':common_args .get ('app_base_dir',None ),
|
||||
# Reconstruct the post_page_url, which is needed by the download function
|
||||
service = job_details.get('service', 'unknown_service')
|
||||
user_id = job_details.get('user_id', 'unknown_user')
|
||||
post_id = job_details.get('original_post_id_for_log', 'unknown_id')
|
||||
api_url_input = job_details.get('api_url_input', '')
|
||||
parsed_api_url = urlparse(api_url_input)
|
||||
api_domain = parsed_api_url.netloc if parsed_api_url.netloc else self._get_domain_for_service(service)
|
||||
post_page_url = f"https://{api_domain}/{service}/user/{user_id}/post/{post_id}"
|
||||
|
||||
# Prepare all arguments for the PostProcessorWorker
|
||||
ppw_init_args = {
|
||||
**common_args,
|
||||
'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 (
|
||||
file_info =job_details ['file_info'],
|
||||
target_folder_path =job_details ['target_folder_path'],
|
||||
headers =job_details ['headers'],
|
||||
original_post_id_for_log =job_details ['original_post_id_for_log'],
|
||||
skip_event =None ,
|
||||
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')
|
||||
worker = PostProcessorWorker(**ppw_init_args)
|
||||
|
||||
# Call the download method with the corrected arguments
|
||||
dl_count, skip_count, filename_saved, original_kept, status, _ = worker._download_single_file(
|
||||
file_info=job_details['file_info'],
|
||||
target_folder_path=job_details['target_folder_path'],
|
||||
post_page_url=post_page_url, # Using the correct argument
|
||||
original_post_id_for_log=job_details['original_post_id_for_log'],
|
||||
skip_event=None,
|
||||
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
|
||||
|
||||
def _handle_retry_future_result (self ,future ):
|
||||
self .processed_retry_count +=1
|
||||
@@ -4937,14 +4946,15 @@ class DownloaderApp (QWidget ):
|
||||
with QMutexLocker (self .prompt_mutex ):self ._add_character_response =result
|
||||
self .log_signal .emit (f" Main thread received character prompt response: {'Action resulted in addition/confirmation'if result else 'Action resulted in no addition/declined'}")
|
||||
|
||||
def _update_multipart_toggle_button_text (self ):
|
||||
if hasattr (self ,'multipart_toggle_button'):
|
||||
if self .allow_multipart_download_setting :
|
||||
self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON"))
|
||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON"))
|
||||
else :
|
||||
self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF"))
|
||||
self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF"))
|
||||
def _update_multipart_toggle_button_text(self):
|
||||
if hasattr(self, 'multipart_toggle_button'):
|
||||
if self.allow_multipart_download_setting:
|
||||
scope_text = self.multipart_scope.capitalize()
|
||||
self.multipart_toggle_button.setText(self._tr("multipart_on_button_text", f"Multi-part: {scope_text}"))
|
||||
self.multipart_toggle_button.setToolTip(self._tr("multipart_on_button_tooltip", f"Multipart download is ON. Applied to: {scope_text} files. Click to change."))
|
||||
else:
|
||||
self.multipart_toggle_button.setText(self._tr("multipart_off_button_text", "Multi-part: OFF"))
|
||||
self.multipart_toggle_button.setToolTip(self._tr("multipart_off_button_tooltip", "Multipart download is OFF. Click to enable and set options."))
|
||||
|
||||
def _update_error_button_count(self):
|
||||
"""Updates the Error button text to show the count of failed files."""
|
||||
@@ -4959,36 +4969,25 @@ class DownloaderApp (QWidget ):
|
||||
else:
|
||||
self.error_btn.setText(base_text)
|
||||
|
||||
def _toggle_multipart_mode (self ):
|
||||
if not self .allow_multipart_download_setting :
|
||||
msg_box =QMessageBox (self )
|
||||
msg_box .setIcon (QMessageBox .Warning )
|
||||
msg_box .setWindowTitle ("Multi-part Download Advisory")
|
||||
msg_box .setText (
|
||||
"<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 :
|
||||
self .log_signal .emit ("ℹ️ Multi-part download enabling cancelled by user.")
|
||||
return
|
||||
|
||||
self .allow_multipart_download_setting =not self .allow_multipart_download_setting
|
||||
self ._update_multipart_toggle_button_text ()
|
||||
self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting )
|
||||
self .log_signal .emit (f"ℹ️ Multi-part download set to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}")
|
||||
def _toggle_multipart_mode(self):
|
||||
"""
|
||||
Opens the Multipart Scope Dialog and updates settings based on user choice.
|
||||
"""
|
||||
current_scope = self.multipart_scope if self.allow_multipart_download_setting else 'both'
|
||||
dialog = MultipartScopeDialog(current_scope, self.multipart_parts_count, self.multipart_min_size_mb, self)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.multipart_scope = dialog.get_selected_scope()
|
||||
self.multipart_parts_count = dialog.get_selected_parts()
|
||||
self.multipart_min_size_mb = dialog.get_selected_min_size() # Get the new value
|
||||
self.allow_multipart_download_setting = True
|
||||
self.log_signal.emit(f"ℹ️ Multi-part download enabled: Scope='{self.multipart_scope.capitalize()}', Parts={self.multipart_parts_count}, Min Size={self.multipart_min_size_mb} MB")
|
||||
else:
|
||||
self.allow_multipart_download_setting = False
|
||||
self.log_signal.emit("ℹ️ Multi-part download setting remains OFF.")
|
||||
|
||||
self._update_multipart_toggle_button_text()
|
||||
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
|
||||
|
||||
def _open_known_txt_file (self ):
|
||||
if not os .path .exists (self .config_file ):
|
||||
|
||||
Reference in New Issue
Block a user