mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Commit
This commit is contained in:
@@ -78,6 +78,7 @@ class PostProcessorWorker:
|
||||
creator_download_folder_ignore_words =None ,
|
||||
manga_global_file_counter_ref =None ,
|
||||
use_date_prefix_for_subfolder=False,
|
||||
keep_in_post_duplicates=False,
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
):
|
||||
@@ -130,6 +131,7 @@ class PostProcessorWorker:
|
||||
self .scan_content_for_images =scan_content_for_images
|
||||
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words
|
||||
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
|
||||
self.keep_in_post_duplicates = keep_in_post_duplicates
|
||||
self.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
if self .compress_images and Image is None :
|
||||
@@ -555,58 +557,175 @@ class PostProcessorWorker:
|
||||
final_total_for_progress =total_size_bytes if download_successful_flag and total_size_bytes >0 else downloaded_size_bytes
|
||||
self ._emit_signal ('file_progress',api_original_filename ,(downloaded_size_bytes ,final_total_for_progress ))
|
||||
|
||||
if self .check_cancel ()or (skip_event and skip_event .is_set ())or (self .pause_event and self .pause_event .is_set ()and not download_successful_flag ):
|
||||
self .logger (f" ⚠️ Download process interrupted for {api_original_filename }.")
|
||||
if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ):
|
||||
try :os .remove (downloaded_part_file_path )
|
||||
except OSError :pass
|
||||
return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None
|
||||
# Rescue download if an IncompleteRead error occurred but the file is complete
|
||||
if (not download_successful_flag and
|
||||
isinstance(last_exception_for_retry_later, http.client.IncompleteRead) and
|
||||
total_size_bytes > 0 and downloaded_part_file_path and os.path.exists(downloaded_part_file_path)):
|
||||
try:
|
||||
actual_size = os.path.getsize(downloaded_part_file_path)
|
||||
if actual_size == total_size_bytes:
|
||||
self.logger(f" ✅ Rescued '{api_original_filename}': IncompleteRead error occurred, but file size matches. Proceeding with save.")
|
||||
download_successful_flag = True
|
||||
# The hash must be recalculated now that we've verified the file
|
||||
md5_hasher = hashlib.md5()
|
||||
with open(downloaded_part_file_path, 'rb') as f_verify:
|
||||
for chunk in iter(lambda: f_verify.read(8192), b""): # Read in chunks
|
||||
md5_hasher.update(chunk)
|
||||
calculated_file_hash = md5_hasher.hexdigest()
|
||||
except Exception as rescue_exc:
|
||||
self.logger(f" ⚠️ Failed to rescue file despite matching size. Error: {rescue_exc}")
|
||||
|
||||
if not download_successful_flag :
|
||||
self .logger (f"❌ Download failed for '{api_original_filename }' after {max_retries +1 } attempts.")
|
||||
if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag):
|
||||
self.logger(f" ⚠️ Download process interrupted for {api_original_filename}.")
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try: os.remove(downloaded_part_file_path)
|
||||
except OSError: pass
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
# This logic block now correctly handles all outcomes: success, failure, or rescued.
|
||||
if download_successful_flag:
|
||||
# --- This is the success path ---
|
||||
if self._check_pause(f"Post-download hash check for '{api_original_filename}'"):
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
is_actually_incomplete_read =False
|
||||
if isinstance (last_exception_for_retry_later ,http .client .IncompleteRead ):
|
||||
is_actually_incomplete_read =True
|
||||
elif hasattr (last_exception_for_retry_later ,'__cause__')and isinstance (last_exception_for_retry_later .__cause__ ,http .client .IncompleteRead ):
|
||||
is_actually_incomplete_read =True
|
||||
with self.downloaded_file_hashes_lock:
|
||||
if calculated_file_hash in self.downloaded_file_hashes:
|
||||
self.logger(f" -> Skip Saving Duplicate (Hash Match): '{api_original_filename}' (Hash: {calculated_file_hash[:8]}...).")
|
||||
with self.downloaded_files_lock: self.downloaded_files.add(filename_to_save_in_main_path)
|
||||
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 file for hash duplicate: {e_rem}")
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
elif last_exception_for_retry_later is not None :
|
||||
str_exc =str (last_exception_for_retry_later ).lower ()
|
||||
effective_save_folder = target_folder_path
|
||||
filename_after_styling_and_word_removal = filename_to_save_in_main_path
|
||||
|
||||
if "incompleteread"in str_exc or (isinstance (last_exception_for_retry_later ,tuple )and any ("incompleteread"in str (arg ).lower ()for arg in last_exception_for_retry_later if isinstance (arg ,(str ,Exception )))):
|
||||
is_actually_incomplete_read =True
|
||||
try:
|
||||
os.makedirs(effective_save_folder, exist_ok=True)
|
||||
except OSError as e:
|
||||
self.logger(f" ❌ Critical error creating directory '{effective_save_folder}': {e}. Skipping file '{api_original_filename}'.")
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try: os.remove(downloaded_part_file_path)
|
||||
except OSError: pass
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
if is_actually_incomplete_read :
|
||||
self .logger (f" Marking '{api_original_filename }' for potential retry later due to IncompleteRead.")
|
||||
retry_later_details ={
|
||||
'file_info':file_info ,
|
||||
'target_folder_path':target_folder_path ,
|
||||
'headers':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 ,
|
||||
'manga_mode_active_for_file':self .manga_mode_active ,
|
||||
'manga_filename_style_for_file':self .manga_filename_style ,
|
||||
data_to_write_io = None
|
||||
filename_after_compression = filename_after_styling_and_word_removal
|
||||
is_img_for_compress_check = is_image(api_original_filename)
|
||||
|
||||
if is_img_for_compress_check and self.compress_images and Image and downloaded_size_bytes > (1.5 * 1024 * 1024):
|
||||
# ... (This block for image compression remains the same)
|
||||
self .logger (f" Compressing '{api_original_filename }' ({downloaded_size_bytes /(1024 *1024 ):.2f} MB)...")
|
||||
if self ._check_pause (f"Image compression for '{api_original_filename }'"):return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None
|
||||
img_content_for_pillow =None
|
||||
try :
|
||||
with open (downloaded_part_file_path ,'rb')as f_img_in :
|
||||
img_content_for_pillow =BytesIO (f_img_in .read ())
|
||||
with Image .open (img_content_for_pillow )as img_obj :
|
||||
if img_obj .mode =='P':img_obj =img_obj .convert ('RGBA')
|
||||
elif img_obj .mode not in ['RGB','RGBA','L']:img_obj =img_obj .convert ('RGB')
|
||||
compressed_output_io =BytesIO ()
|
||||
img_obj .save (compressed_output_io ,format ='WebP',quality =80 ,method =4 )
|
||||
compressed_size =compressed_output_io .getbuffer ().nbytes
|
||||
if compressed_size <downloaded_size_bytes *0.9 :
|
||||
self .logger (f" Compression success: {compressed_size /(1024 *1024 ):.2f} MB.")
|
||||
data_to_write_io =compressed_output_io
|
||||
data_to_write_io .seek (0 )
|
||||
base_name_orig ,_ =os .path .splitext (filename_after_compression )
|
||||
filename_after_compression =base_name_orig +'.webp'
|
||||
self .logger (f" Updated filename (compressed): {filename_after_compression }")
|
||||
else :
|
||||
self .logger (f" Compression skipped: WebP not significantly smaller.")
|
||||
if compressed_output_io :compressed_output_io .close ()
|
||||
except Exception as comp_e :
|
||||
self .logger (f"❌ Compression failed for '{api_original_filename }': {comp_e }. Saving original.")
|
||||
finally :
|
||||
if img_content_for_pillow :img_content_for_pillow .close ()
|
||||
|
||||
final_filename_on_disk = filename_after_compression
|
||||
temp_base, temp_ext = os.path.splitext(final_filename_on_disk)
|
||||
suffix_counter = 1
|
||||
while os.path.exists(os.path.join(effective_save_folder, final_filename_on_disk)):
|
||||
final_filename_on_disk = f"{temp_base}_{suffix_counter}{temp_ext}"
|
||||
suffix_counter += 1
|
||||
if final_filename_on_disk != filename_after_compression:
|
||||
self.logger(f" Applied numeric suffix in '{os.path.basename(effective_save_folder)}': '{final_filename_on_disk}' (was '{filename_after_compression}')")
|
||||
|
||||
if self._check_pause(f"File saving for '{final_filename_on_disk}'"):
|
||||
return 0, 1, final_filename_on_disk, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
final_save_path = os.path.join(effective_save_folder, final_filename_on_disk)
|
||||
try:
|
||||
if data_to_write_io:
|
||||
with open(final_save_path, 'wb') as f_out:
|
||||
time.sleep(0.05)
|
||||
f_out.write(data_to_write_io.getvalue())
|
||||
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:
|
||||
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)
|
||||
else:
|
||||
raise FileNotFoundError(f"Original .part file not found for saving: {downloaded_part_file_path}")
|
||||
|
||||
with self.downloaded_file_hashes_lock: self.downloaded_file_hashes.add(calculated_file_hash)
|
||||
with self.downloaded_files_lock: self.downloaded_files.add(filename_to_save_in_main_path)
|
||||
|
||||
final_filename_saved_for_return = final_filename_on_disk
|
||||
self.logger(f"✅ Saved: '{final_filename_saved_for_return}' (from '{api_original_filename}', {downloaded_size_bytes / (1024 * 1024):.2f} MB) in '{os.path.basename(effective_save_folder)}'")
|
||||
|
||||
downloaded_file_details = {
|
||||
'disk_filename': final_filename_saved_for_return,
|
||||
'post_title': post_title,
|
||||
'post_id': original_post_id_for_log,
|
||||
'upload_date_str': self.post.get('published') or self.post.get('added') or "N/A",
|
||||
'download_timestamp': time.time(),
|
||||
'download_path': effective_save_folder,
|
||||
'service': self.service,
|
||||
'user_id': self.user_id,
|
||||
'api_original_filename': api_original_filename,
|
||||
'folder_context_name': folder_context_name_for_history or os.path.basename(effective_save_folder)
|
||||
}
|
||||
return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER ,retry_later_details
|
||||
else :
|
||||
self .logger (f" Marking '{api_original_filename }' as permanently failed for this session.")
|
||||
permanent_failure_details ={
|
||||
'file_info':file_info ,
|
||||
'target_folder_path':target_folder_path ,
|
||||
'headers':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 ,
|
||||
}
|
||||
return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION ,permanent_failure_details
|
||||
if self ._check_pause (f"Post-download hash check for '{api_original_filename }'"):return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None
|
||||
self._emit_signal('file_successfully_downloaded', downloaded_file_details)
|
||||
time.sleep(0.05)
|
||||
|
||||
return 1, 0, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SUCCESS, None
|
||||
except Exception as save_err:
|
||||
self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}")
|
||||
if os.path.exists(final_save_path):
|
||||
try: os.remove(final_save_path)
|
||||
except OSError: self.logger(f" -> Failed to remove partially saved file: {final_save_path}")
|
||||
return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
finally:
|
||||
if data_to_write_io and hasattr(data_to_write_io, 'close'):
|
||||
data_to_write_io.close()
|
||||
|
||||
else:
|
||||
# --- This is the failure path ---
|
||||
self.logger(f"❌ Download failed for '{api_original_filename}' after {max_retries + 1} attempts.")
|
||||
|
||||
is_actually_incomplete_read = False
|
||||
if isinstance(last_exception_for_retry_later, http.client.IncompleteRead):
|
||||
is_actually_incomplete_read = True
|
||||
elif hasattr(last_exception_for_retry_later, '__cause__') and isinstance(last_exception_for_retry_later.__cause__, http.client.IncompleteRead):
|
||||
is_actually_incomplete_read = True
|
||||
elif last_exception_for_retry_later is not None:
|
||||
str_exc = str(last_exception_for_retry_later).lower()
|
||||
if "incompleteread" in str_exc or (isinstance(last_exception_for_retry_later, tuple) and any("incompleteread" in str(arg).lower() for arg in last_exception_for_retry_later if isinstance(arg, (str, Exception)))):
|
||||
is_actually_incomplete_read = True
|
||||
|
||||
if is_actually_incomplete_read:
|
||||
self.logger(f" Marking '{api_original_filename}' for potential retry later due to IncompleteRead.")
|
||||
retry_later_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': 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, 'manga_mode_active_for_file': self.manga_mode_active, 'manga_filename_style_for_file': self.manga_filename_style, }
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details
|
||||
else:
|
||||
self.logger(f" Marking '{api_original_filename}' as permanently failed for this session.")
|
||||
permanent_failure_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': 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, }
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
|
||||
with self .downloaded_file_hashes_lock :
|
||||
if calculated_file_hash in self .downloaded_file_hashes :
|
||||
self .logger (f" -> Skip Saving Duplicate (Hash Match): '{api_original_filename }' (Hash: {calculated_file_hash [:8 ]}...).")
|
||||
@@ -1207,16 +1326,26 @@ class PostProcessorWorker:
|
||||
return 0 ,0 ,[],[],[],None
|
||||
files_to_download_info_list =[]
|
||||
processed_original_filenames_in_this_post =set ()
|
||||
for file_info in all_files_from_post_api :
|
||||
current_api_original_filename =file_info .get ('_original_name_for_log')
|
||||
if current_api_original_filename in processed_original_filenames_in_this_post :
|
||||
self .logger (f" -> Skip Duplicate Original Name (within post {post_id }): '{current_api_original_filename }' already processed/listed for this post.")
|
||||
total_skipped_this_post +=1
|
||||
else :
|
||||
files_to_download_info_list .append (file_info )
|
||||
if current_api_original_filename :
|
||||
processed_original_filenames_in_this_post .add (current_api_original_filename )
|
||||
if not files_to_download_info_list :
|
||||
|
||||
if self.keep_in_post_duplicates:
|
||||
# If we keep duplicates, just add every file to the list to be processed.
|
||||
# The downstream hash check and rename-on-collision logic will handle them.
|
||||
files_to_download_info_list.extend(all_files_from_post_api)
|
||||
self.logger(f" ℹ️ 'Keep Duplicates' is on. All {len(all_files_from_post_api)} files from post will be processed.")
|
||||
else:
|
||||
# This is the original logic that skips duplicates by name within a post.
|
||||
for file_info in all_files_from_post_api:
|
||||
current_api_original_filename = file_info.get('_original_name_for_log')
|
||||
if current_api_original_filename in processed_original_filenames_in_this_post:
|
||||
self.logger(f" -> Skip Duplicate Original Name (within post {post_id}): '{current_api_original_filename}' already processed/listed for this post.")
|
||||
total_skipped_this_post += 1
|
||||
else:
|
||||
files_to_download_info_list.append(file_info)
|
||||
if current_api_original_filename:
|
||||
processed_original_filenames_in_this_post.add(current_api_original_filename)
|
||||
|
||||
if not files_to_download_info_list:
|
||||
|
||||
self .logger (f" All files for post {post_id } were duplicate original names or skipped earlier.")
|
||||
return 0 ,total_skipped_this_post ,[],[],[],None
|
||||
|
||||
@@ -1366,12 +1495,24 @@ class PostProcessorWorker:
|
||||
with open(self.session_file_path, 'r', encoding='utf-8') as f:
|
||||
session_data = json.load(f)
|
||||
|
||||
# Modify in memory
|
||||
if not isinstance(session_data.get('download_state', {}).get('processed_post_ids'), list):
|
||||
if 'download_state' not in session_data:
|
||||
session_data['download_state'] = {}
|
||||
if 'download_state' not in session_data:
|
||||
session_data['download_state'] = {}
|
||||
|
||||
# Add processed ID
|
||||
if not isinstance(session_data['download_state'].get('processed_post_ids'), list):
|
||||
session_data['download_state']['processed_post_ids'] = []
|
||||
session_data['download_state']['processed_post_ids'].append(self.post.get('id'))
|
||||
|
||||
# Add any permanent failures from this worker to the session file
|
||||
if permanent_failures_this_post:
|
||||
if not isinstance(session_data['download_state'].get('permanently_failed_files'), list):
|
||||
session_data['download_state']['permanently_failed_files'] = []
|
||||
# To avoid duplicates if the same post is somehow re-processed
|
||||
existing_failed_urls = {f.get('file_info', {}).get('url') for f in session_data['download_state']['permanently_failed_files']}
|
||||
for failure in permanent_failures_this_post:
|
||||
if failure.get('file_info', {}).get('url') not in existing_failed_urls:
|
||||
session_data['download_state']['permanently_failed_files'].append(failure)
|
||||
|
||||
# Write to temp file and then atomically replace
|
||||
temp_file_path = self.session_file_path + ".tmp"
|
||||
with open(temp_file_path, 'w', encoding='utf-8') as f_tmp:
|
||||
@@ -1460,6 +1601,7 @@ class DownloadThread (QThread ):
|
||||
scan_content_for_images =False ,
|
||||
creator_download_folder_ignore_words =None ,
|
||||
use_date_prefix_for_subfolder=False,
|
||||
keep_in_post_duplicates=False,
|
||||
cookie_text ="",
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
@@ -1513,6 +1655,7 @@ class DownloadThread (QThread ):
|
||||
self .scan_content_for_images =scan_content_for_images
|
||||
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words
|
||||
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
|
||||
self.keep_in_post_duplicates = keep_in_post_duplicates
|
||||
self .manga_global_file_counter_ref =manga_global_file_counter_ref
|
||||
self.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
@@ -1646,6 +1789,7 @@ class DownloadThread (QThread ):
|
||||
use_cookie =self .use_cookie ,
|
||||
manga_date_file_counter_ref =self .manga_date_file_counter_ref ,
|
||||
use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder,
|
||||
keep_in_post_duplicates=self.keep_in_post_duplicates,
|
||||
creator_download_folder_ignore_words =self .creator_download_folder_ignore_words ,
|
||||
session_file_path=self.session_file_path,
|
||||
session_lock=self.session_lock,
|
||||
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import QUrl, QSize, Qt
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtGui import QIcon, QDesktopServices
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QScrollArea, QFrame, QWidget
|
||||
|
||||
@@ -241,7 +241,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 v5.5.0")
|
||||
self.setWindowTitle("Kemono Downloader v6.0.0")
|
||||
self.init_ui()
|
||||
self._connect_signals()
|
||||
self.log_signal.emit("ℹ️ Local API server functionality has been removed.")
|
||||
@@ -274,6 +274,8 @@ class DownloaderApp (QWidget ):
|
||||
'use_subfolder_per_post_checkbox': 'use_post_subfolders',
|
||||
'use_multithreading_checkbox': 'use_multithreading',
|
||||
'external_links_checkbox': 'show_external_links',
|
||||
'keep_duplicates_checkbox': 'keep_in_post_duplicates',
|
||||
'date_prefix_checkbox': 'use_date_prefix_for_subfolder',
|
||||
'manga_mode_checkbox': 'manga_mode_active',
|
||||
'scan_content_images_checkbox': 'scan_content_for_images',
|
||||
'use_cookie_checkbox': 'use_cookie',
|
||||
@@ -342,6 +344,12 @@ class DownloaderApp (QWidget ):
|
||||
if "ui_settings" not in session_data or "download_state" not in session_data:
|
||||
raise ValueError("Invalid session file structure.")
|
||||
|
||||
failed_files_from_session = session_data.get('download_state', {}).get('permanently_failed_files', [])
|
||||
if failed_files_from_session:
|
||||
self.permanently_failed_files_for_dialog.clear()
|
||||
self.permanently_failed_files_for_dialog.extend(failed_files_from_session)
|
||||
self.log_signal.emit(f"ℹ️ Restored {len(failed_files_from_session)} failed file entries from the previous session.")
|
||||
|
||||
self.interrupted_session_data = session_data
|
||||
self.log_signal.emit("ℹ️ Incomplete download session found. UI updated for restore.")
|
||||
self._prepare_ui_for_restore()
|
||||
@@ -422,11 +430,13 @@ class DownloaderApp (QWidget ):
|
||||
self.pause_btn.setEnabled(True)
|
||||
self.pause_btn.clicked.connect(self.restore_download)
|
||||
self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download."))
|
||||
self.cancel_btn.setEnabled(True)
|
||||
|
||||
self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
|
||||
self.cancel_btn.setEnabled(False) # Nothing to cancel yet
|
||||
self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
|
||||
# --- START: CORRECTED CANCEL BUTTON LOGIC ---
|
||||
self.cancel_btn.setText(self._tr("discard_session_button_text", "🗑️ Discard Session"))
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.clicked.connect(self._clear_session_and_reset_ui)
|
||||
self.cancel_btn.setToolTip(self._tr("discard_session_tooltip", "Click to discard the interrupted session and reset the UI."))
|
||||
|
||||
elif is_download_active:
|
||||
# State: Downloading / Paused
|
||||
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
|
||||
@@ -1206,6 +1216,11 @@ class DownloaderApp (QWidget ):
|
||||
self .compress_images_checkbox .setToolTip ("Compress images > 1.5MB to WebP format (requires Pillow).")
|
||||
row1_layout .addWidget (self .compress_images_checkbox )
|
||||
|
||||
self.keep_duplicates_checkbox = QCheckBox("Keep Duplicates")
|
||||
self.keep_duplicates_checkbox.setChecked(False)
|
||||
self.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.\nUnique files will be renamed with a suffix; identical files will still be skipped by hash.")
|
||||
row1_layout.addWidget(self.keep_duplicates_checkbox)
|
||||
|
||||
row1_layout .addStretch (1 )
|
||||
checkboxes_group_layout .addLayout (row1_layout )
|
||||
|
||||
@@ -3578,6 +3593,7 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self .retryable_failed_files_info .clear ()
|
||||
self .permanently_failed_files_for_dialog .clear ()
|
||||
self._update_error_button_count()
|
||||
|
||||
manga_date_file_counter_ref_for_thread =None
|
||||
if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED and not extract_links_only :
|
||||
@@ -3627,6 +3643,9 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
if not extract_links_only :
|
||||
log_messages .append (f" Subfolders: {'Enabled'if use_subfolders else 'Disabled'}")
|
||||
if use_subfolders and self.use_subfolder_per_post_checkbox.isChecked():
|
||||
use_date_prefix = self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False
|
||||
log_messages.append(f" ↳ Date Prefix for Post Subfolders: {'Enabled' if use_date_prefix else 'Disabled'}")
|
||||
if use_subfolders :
|
||||
if custom_folder_name_cleaned :log_messages .append (f" Custom Folder (Post): '{custom_folder_name_cleaned }'")
|
||||
if actual_filters_to_use_for_run :
|
||||
@@ -3636,14 +3655,15 @@ class DownloaderApp (QWidget ):
|
||||
log_messages .append (f" Folder Naming: Automatic (based on title/known names)")
|
||||
|
||||
|
||||
log_messages .extend ([
|
||||
f" File Type Filter: {user_selected_filter_text } (Backend processing as: {backend_filter_mode })",
|
||||
f" Skip Archives: {'.zip'if effective_skip_zip else ''}{', 'if effective_skip_zip and effective_skip_rar else ''}{'.rar'if effective_skip_rar else ''}{'None (Archive Mode)'if backend_filter_mode =='archive'else ('None'if not (effective_skip_zip or effective_skip_rar )else '')}",
|
||||
f" Skip Words (posts/files): {', '.join (skip_words_list )if skip_words_list else 'None'}",
|
||||
f" Skip Words Scope: {current_skip_words_scope .capitalize ()}",
|
||||
f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}",
|
||||
f" Compress Images: {'Enabled'if compress_images else 'Disabled'}",
|
||||
f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}"
|
||||
keep_duplicates = self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False
|
||||
log_messages.extend([
|
||||
f" File Type Filter: {user_selected_filter_text} (Backend processing as: {backend_filter_mode})",
|
||||
f" Keep In-Post Duplicates: {'Enabled' if keep_duplicates else 'Disabled'}",
|
||||
f" Skip Archives: {'.zip' if effective_skip_zip else ''}{', ' if effective_skip_zip and effective_skip_rar else ''}{'.rar' if effective_skip_rar else ''}{'None (Archive Mode)' if backend_filter_mode == 'archive' else ('None' if not (effective_skip_zip or effective_skip_rar) else '')}",
|
||||
f" Skip Words Scope: {current_skip_words_scope .capitalize ()}",
|
||||
f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}",
|
||||
f" Compress Images: {'Enabled'if compress_images else 'Disabled'}",
|
||||
f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}"
|
||||
])
|
||||
log_messages .append (f" Scan Post Content for Images: {'Enabled'if scan_content_for_images else 'Disabled'}")
|
||||
else :
|
||||
@@ -3731,6 +3751,7 @@ class DownloaderApp (QWidget ):
|
||||
'session_lock': self.session_lock,
|
||||
'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run ,
|
||||
'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False,
|
||||
'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False,
|
||||
}
|
||||
|
||||
args_template ['override_output_dir']=override_output_dir
|
||||
@@ -3830,6 +3851,7 @@ class DownloaderApp (QWidget ):
|
||||
dialog .exec_ ()
|
||||
def _handle_retry_from_error_dialog (self ,selected_files_to_retry ):
|
||||
self ._start_failed_files_retry_session (files_to_retry_list =selected_files_to_retry )
|
||||
self._update_error_button_count()
|
||||
|
||||
def _handle_retryable_file_failure (self ,list_of_retry_details ):
|
||||
"""Appends details of files that failed but might be retryable later."""
|
||||
@@ -3841,6 +3863,7 @@ class DownloaderApp (QWidget ):
|
||||
if list_of_permanent_failure_details :
|
||||
self .permanently_failed_files_for_dialog .extend (list_of_permanent_failure_details )
|
||||
self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
|
||||
self._update_error_button_count()
|
||||
|
||||
def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
|
||||
"""Helper to prepare and submit a single post processing task to the thread pool."""
|
||||
@@ -4129,6 +4152,7 @@ class DownloaderApp (QWidget ):
|
||||
'manga_filename_style',
|
||||
'manga_date_prefix',
|
||||
'use_date_prefix_for_subfolder',
|
||||
'keep_in_post_duplicates',
|
||||
'manga_global_file_counter_ref',
|
||||
'creator_download_folder_ignore_words',
|
||||
'session_file_path',
|
||||
@@ -4242,6 +4266,7 @@ class DownloaderApp (QWidget ):
|
||||
self ._add_to_history_candidates (history_data_from_worker )
|
||||
if permanent_failures_from_post :
|
||||
self .permanently_failed_files_for_dialog .extend (permanent_failures_from_post )
|
||||
self._update_error_button_count()
|
||||
self ._add_to_history_candidates (history_data_from_worker )
|
||||
with self .downloaded_files_lock :
|
||||
self .download_counter +=downloaded_files_from_future
|
||||
@@ -4955,6 +4980,7 @@ class DownloaderApp (QWidget ):
|
||||
# --- Reset UI and all state ---
|
||||
self.log_signal.emit("🔄 Resetting application state to defaults...")
|
||||
self._reset_ui_to_defaults()
|
||||
self._load_saved_download_location()
|
||||
self.main_log_output.clear()
|
||||
self.external_log_output.clear()
|
||||
if self.missed_character_log_output:
|
||||
@@ -4989,6 +5015,7 @@ class DownloaderApp (QWidget ):
|
||||
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
|
||||
self.mega_download_log_preserved_once = False
|
||||
self.permanently_failed_files_for_dialog.clear()
|
||||
self._update_error_button_count()
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION
|
||||
self._update_favorite_scope_button_text()
|
||||
self.retryable_failed_files_info.clear()
|
||||
@@ -5024,7 +5051,6 @@ class DownloaderApp (QWidget ):
|
||||
"""Resets all UI elements and relevant state to their default values."""
|
||||
# Clear all text fields
|
||||
self.link_input.clear()
|
||||
self.dir_input.clear()
|
||||
self.custom_folder_input.clear()
|
||||
self.character_input.clear()
|
||||
self.skip_words_input.clear()
|
||||
@@ -5207,6 +5233,19 @@ class DownloaderApp (QWidget ):
|
||||
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_error_button_count(self):
|
||||
"""Updates the Error button text to show the count of failed files."""
|
||||
if not hasattr(self, 'error_btn'):
|
||||
return
|
||||
|
||||
count = len(self.permanently_failed_files_for_dialog)
|
||||
base_text = self._tr("error_button_text", "Error")
|
||||
|
||||
if count > 0:
|
||||
self.error_btn.setText(f"({count}) {base_text}")
|
||||
else:
|
||||
self.error_btn.setText(base_text)
|
||||
|
||||
def _toggle_multipart_mode (self ):
|
||||
if not self .allow_multipart_download_setting :
|
||||
msg_box =QMessageBox (self )
|
||||
|
||||
Reference in New Issue
Block a user