This commit is contained in:
Yuvi63771
2025-11-04 09:13:54 +05:30
parent 257111d462
commit 4a93b721e2
4 changed files with 643 additions and 80 deletions

View File

@@ -149,6 +149,7 @@ class DownloaderApp (QWidget ):
external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
file_progress_signal =pyqtSignal (str ,object )
fetch_only_complete_signal = pyqtSignal(list)
batch_update_check_complete_signal = pyqtSignal(list)
def __init__(self):
@@ -156,6 +157,10 @@ class DownloaderApp (QWidget ):
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
self.active_update_profile = None
self.new_posts_for_update = []
self.active_update_profiles_list = [] # For batch updates
self.fetched_posts_for_batch_update = [] # Stores {'post_data': ..., 'creator_settings': ...}
self.is_ready_to_download_batch_update = False
self.is_finishing = False
self.finish_lock = threading.Lock()
@@ -334,7 +339,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 v7.5.2")
self.setWindowTitle("Kemono Downloader v7.6.0")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@@ -775,6 +780,17 @@ class DownloaderApp (QWidget ):
self.cancel_btn.clicked.connect(self.reset_application_state)
return # <-- This 'return' is CRITICAL
elif self.is_ready_to_download_batch_update:
num_posts = len(self.fetched_posts_for_batch_update)
self.download_btn.setText(f"⬇️ Start Download ({num_posts} New Posts)")
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.pause_btn.setEnabled(False)
self.cancel_btn.setText("🗑️ Clear Update")
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self.reset_application_state)
return
if self.active_update_profile and self.new_posts_for_update and not is_download_active:
# State: Update confirmation (new posts found, waiting for user to start)
num_new = len(self.new_posts_for_update)
@@ -1130,6 +1146,7 @@ class DownloaderApp (QWidget ):
self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
self.actual_gui_signals.worker_finished_signal.connect(self._handle_worker_result)
self .actual_gui_signals .file_download_status_signal .connect (lambda status :None )
self.batch_update_check_complete_signal.connect(self._batch_update_check_finished)
self.fetch_only_complete_signal.connect(self._fetch_only_finished)
if hasattr (self ,'character_input'):
@@ -1795,7 +1812,9 @@ class DownloaderApp (QWidget ):
supported_platforms_for_button ={'mega','google drive','dropbox'}
has_supported_links =any (
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
for link_info in self.extracted_links_cache
)
self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links )
@@ -3515,6 +3534,18 @@ class DownloaderApp (QWidget ):
return get_theme_stylesheet(actual_scale)
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
if not is_restore and not is_continuation:
if self.main_log_output: self.main_log_output.clear()
if self.external_log_output: self.external_log_output.clear()
if self.missed_character_log_output: self.missed_character_log_output.clear()
self.missed_key_terms_buffer.clear()
self.already_logged_bold_key_terms.clear()
if self.is_ready_to_download_batch_update:
self._start_download_of_batch_update()
return True
if not direct_api_url:
api_url_text = self.link_input.text().strip().lower()
batch_handlers = {
@@ -4411,6 +4442,7 @@ class DownloaderApp (QWidget ):
if self.pause_event: self.pause_event.clear()
self.is_paused = False
return True
def restore_download(self):
"""Initiates the download restoration process."""
if self._is_download_active():
@@ -4574,6 +4606,294 @@ class DownloaderApp (QWidget ):
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 _start_batch_update_check(self, profiles_list):
"""Launches a background thread to check multiple profiles for updates."""
self.set_ui_enabled(False)
self.progress_label.setText(self._tr("batch_update_checking", "Checking for updates..."))
self.cancellation_event.clear()
# Start the background thread
self.download_thread = threading.Thread(
target=self._run_batch_update_check_thread,
args=(profiles_list,),
daemon=True
)
self.download_thread.start()
self._update_button_states_and_connections()
def _run_batch_update_check_thread(self, profiles_list):
"""
(BACKGROUND THREAD)
Iterates profiles, calls download_from_api for each, and collects new posts.
"""
master_new_post_list = []
total_profiles = len(profiles_list)
for i, profile in enumerate(profiles_list):
if self.cancellation_event.is_set():
break
profile_name = profile.get('name', 'Unknown')
self.log_signal.emit(f"Checking {profile_name} ({i+1}/{total_profiles})...")
try:
profile_data = profile.get('data', {})
url = profile_data.get('creator_url', [])[0] # Get first URL
processed_ids = set(profile_data.get('processed_post_ids', []))
creator_settings = profile_data.get('settings', {})
# Use common cookie settings from the UI
use_cookie = self.use_cookie_checkbox.isChecked()
cookie_text = self.cookie_text_input.text()
cookie_file = self.selected_cookie_filepath
post_generator = download_from_api(
api_url_input=url,
logger=lambda msg: None, # Suppress logs
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=use_cookie,
cookie_text=cookie_text,
selected_cookie_file=cookie_file,
app_base_dir=self.app_base_dir,
processed_post_ids=processed_ids,
end_page=5
)
for post_batch in post_generator:
if self.cancellation_event.is_set(): break
for post_data in post_batch:
# Store the post AND the ENTIRE profile data
master_new_post_list.append({
'post_data': post_data,
'profile_data': profile_data, # Pass the full profile
'creator_name': profile_name
})
except Exception as e:
self.log_signal.emit(f"❌ Error checking {profile_name}: {e}")
# Emit the final aggregated list
self.batch_update_check_complete_signal.emit(master_new_post_list)
def _batch_update_check_finished(self, all_new_posts_list):
"""
(GUI THREAD)
Called when the batch update check is complete. Updates UI.
"""
self.download_thread = None # Clear the thread
if self.cancellation_event.is_set():
self.log_signal.emit(" Update check was cancelled.")
self.reset_application_state() # Full reset
return
if not all_new_posts_list:
self.log_signal.emit("✅ All selected creators are up to date! No new posts found.")
QMessageBox.information(self, "Up to Date", "No new posts were found for the selected creators.")
self.reset_application_state() # Full reset
return
total_posts = len(all_new_posts_list)
# --- MODIFIED BLOCK ---
# Get the set of unique creator names who have new posts
creators_with_new_posts = sorted(list(set(p['creator_name'] for p in all_new_posts_list)))
total_creators = len(creators_with_new_posts)
self.log_signal.emit("=" * 40)
# Add the new line you requested
if creators_with_new_posts:
self.log_signal.emit(f"Creators With New Posts - {', '.join(creators_with_new_posts)}")
# Log the original summary line
self.log_signal.emit(f"✅ Update check complete. Found {total_posts} new post(s) across {total_creators} creator(s).")
# --- END OF MODIFIED BLOCK ---
self.log_signal.emit(" Click 'Start Download' to begin.")
self.fetched_posts_for_batch_update = all_new_posts_list
self.is_ready_to_download_batch_update = True
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
self.set_ui_enabled(True) # Re-enable UI
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
def _start_download_of_batch_update(self):
"""
(GUI THREAD)
Initiates the download of the posts found during the batch update check.
--- THIS IS THE CORRECTED ROBUST VERSION ---
"""
self.is_ready_to_download_batch_update = False
self.log_signal.emit("=" * 40)
self.log_signal.emit(f"🚀 Starting batch download for {len(self.fetched_posts_for_batch_update)} new post(s)...")
if self.main_log_output: self.main_log_output.clear()
if self.external_log_output: self.external_log_output.clear()
if self.missed_character_log_output: self.missed_character_log_output.clear()
self.missed_key_terms_buffer.clear()
self.already_logged_bold_key_terms.clear()
self.set_ui_enabled(False)
num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1
self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='PostWorker_')
self.total_posts_to_process = len(self.fetched_posts_for_batch_update)
self.processed_posts_count = 0
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
# 1. Define all LIVE RUNTIME arguments.
# These are taken from the current app state and are the same for all workers.
live_runtime_args = {
'emitter': self.worker_to_gui_queue,
'creator_name_cache': self.creator_name_cache,
'known_names': list(KNOWN_NAMES),
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
'pause_event': self.pause_event,
'cancellation_event': self.cancellation_event,
'downloaded_files': self.downloaded_files,
'downloaded_files_lock': self.downloaded_files_lock,
'downloaded_file_hashes': self.downloaded_file_hashes,
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
'num_file_threads': 1, # File threads per post worker
'manga_date_file_counter_ref': None,
'manga_global_file_counter_ref': None,
'creator_download_folder_ignore_words': CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS,
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'skip_current_file_flag': None,
'session_file_path': self.session_file_path,
'session_lock': self.session_lock,
'project_root_dir': self.app_base_dir,
'app_base_dir': self.app_base_dir,
'start_offset': 0,
'fetch_first': False,
# Add live cookie settings
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
}
# 2. Define DEFAULTS for all settings that *should* be in the profile.
# These will be used if the profile is old and missing a key.
default_profile_settings = {
'output_dir': self.dir_input.text().strip(), # Fallback to live UI
'api_url': '',
'character_filter_text': '',
'skip_words_text': '',
'remove_words_text': '',
'custom_folder_name': None,
'filter_mode': 'all',
'text_only_scope': None,
'text_export_format': 'txt',
'single_pdf_mode': False,
'skip_zip': False,
'use_subfolders': False,
'use_post_subfolders': False,
'compress_images': False,
'download_thumbnails': False,
'skip_words_scope': SKIP_SCOPE_FILES,
'char_filter_scope': CHAR_SCOPE_FILES,
'show_external_links': False,
'extract_links_only': False,
'manga_mode_active': False,
'manga_filename_style': STYLE_POST_TITLE,
'allow_multipart_download': False,
'manga_date_prefix': '',
'scan_content_for_images': False,
'use_date_prefix_for_subfolder': False,
'date_prefix_format': "YYYY-MM-DD {post}",
'keep_in_post_duplicates': False,
'keep_duplicates_mode': DUPLICATE_HANDLING_HASH,
'keep_duplicates_limit': 0,
'multipart_scope': 'both',
'multipart_parts_count': 4,
'multipart_min_size_mb': 100,
'manga_custom_filename_format': "{published} {title}",
'manga_custom_date_format': "YYYY-MM-DD",
'target_post_id_from_initial_url': None,
'override_output_dir': None,
'processed_post_ids': [],
}
for item in self.fetched_posts_for_batch_update:
post_data = item['post_data']
# --- THIS IS THE NEW, CORRECTED LOGIC ---
full_profile_data = item.get('profile_data', {})
saved_settings = full_profile_data.get('settings', {})
# --- END OF NEW LOGIC ---
# 3. Construct the final arguments for this specific worker
# Start with a full set of defaults
args_for_this_worker = default_profile_settings.copy()
# Overwrite with any settings saved in the profile
# This is where {"filter_mode": "video"} from Maplestar.json is applied
args_for_this_worker.update(saved_settings)
# Add all the live runtime arguments
args_for_this_worker.update(live_runtime_args)
# 4. Manually parse values from the constructed args
# Set post-specific data
args_for_this_worker['service'] = post_data.get('service')
args_for_this_worker['user_id'] = post_data.get('user')
# Set download_root (which worker expects) from output_dir
args_for_this_worker['download_root'] = args_for_this_worker.get('output_dir')
# Parse filters and commands
raw_filters = args_for_this_worker.get('character_filter_text', '')
parsed_filters, commands = self._parse_character_filters(raw_filters)
args_for_this_worker['filter_character_list'] = parsed_filters
args_for_this_worker['domain_override'] = commands.get('domain_override')
args_for_this_worker['archive_only_mode'] = commands.get('archive_only', False)
args_for_this_worker['sfp_threshold'] = commands.get('sfp_threshold')
args_for_this_worker['handle_unknown_mode'] = commands.get('handle_unknown', False)
# Parse skip words and skip size
skip_words_parts = [part.strip() for part in args_for_this_worker.get('skip_words_text', '').split(',') if part.strip()]
args_for_this_worker['skip_file_size_mb'] = None
args_for_this_worker['skip_words_list'] = []
size_pattern = re.compile(r'\[(\d+)\]')
for part in skip_words_parts:
match = size_pattern.fullmatch(part)
if match:
args_for_this_worker['skip_file_size_mb'] = int(match.group(1))
else:
args_for_this_worker['skip_words_list'].append(part.lower())
# Parse remove_from_filename_words_list
raw_remove_words = args_for_this_worker.get('remove_words_text', '')
args_for_this_worker['remove_from_filename_words_list'] = [word.strip() for word in raw_remove_words.split(',') if word.strip()]
# Ensure processed_post_ids is a list (from the *original* profile data)
args_for_this_worker['processed_post_ids'] = list(full_profile_data.get('processed_post_ids', []))
# Ensure api_url_input is set
args_for_this_worker['api_url_input'] = args_for_this_worker.get('api_url', '')
self._submit_post_to_worker_pool(
post_data,
args_for_this_worker,
1, # File threads per worker (1 for sequential batch)
self.worker_to_gui_queue,
ppw_expected_keys,
{}
)
self.fetched_posts_for_batch_update = []
self.is_fetcher_thread_running = False
self._check_if_all_work_is_done()
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."""
global PostProcessorWorker
@@ -5173,56 +5493,76 @@ class DownloaderApp (QWidget ):
self ._filter_links_log ()
def cancel_download_button_action(self):
"""
Handles the user clicking the 'Cancel' button.
This version forcefully shuts down thread pools.
"""
if not self._is_download_active() and not self.is_paused:
self.log_signal.emit(" Cancel button clicked, but no download is active.")
return
if self.is_paused:
self.log_signal.emit("❌ Cancellation requested while paused. Stopping all workers...")
if self._is_download_active() and hasattr(self.download_thread, 'cancel'):
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
self.download_thread.cancel()
else:
# Fallback for other download types
self.cancellation_event.set()
# Update UI to "Cancelling" state
self.pause_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
if hasattr(self, 'reset_button'):
self.reset_button.setEnabled(False)
self.log_signal.emit("❌ Cancellation requested by user. Stopping all workers...")
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
self.pause_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
if hasattr(self, 'reset_button'):
self.reset_button.setEnabled(False)
# 1. Set the master cancellation event
# This tells all workers to stop *cooperatively*
if not self.cancellation_event.is_set():
self.cancellation_event.set()
# Only call QThread-specific methods if the thread is a QThread
# 2. Forcefully shut down QThreads
if self.download_thread and hasattr(self.download_thread, 'requestInterruption'):
self.download_thread.requestInterruption()
self.log_signal.emit(" Signaled single download thread to interrupt.")
if self.thread_pool:
self.log_signal.emit(" Signaling worker pool to cancel futures...")
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.log_signal.emit(" Cancelling active External Link download thread...")
self.external_link_download_thread.cancel()
# ... (add any other QThread .cancel() calls here if you have them) ...
if isinstance(self.download_thread, NhentaiDownloadThread):
self.log_signal.emit(" Signaling nhentai download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, BunkrDownloadThread):
self.log_signal.emit(" Signaling Bunkr download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, Saint2DownloadThread):
self.log_signal.emit(" Signaling Saint2 download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, EromeDownloadThread):
self.log_signal.emit(" Signaling Erome download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, Hentai2readDownloadThread):
self.log_signal.emit(" Signaling Hentai2Read download thread to cancel.")
self.download_thread.cancel()
# 3. Forcefully shut down ThreadPoolExecutors
# This is the critical fix for batch/update downloads
if self.thread_pool:
self.log_signal.emit(" Signaling worker pool to shut down...")
# We use cancel_futures=True to actively stop pending tasks
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.thread_pool = None
self.active_futures = []
self.log_signal.emit(" Worker pool shutdown initiated.")
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Signaling retry worker pool to shut down...")
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
self.retry_thread_pool = None
self.active_retry_futures = []
self.log_signal.emit(" Retry pool shutdown initiated.")
# 4. Manually trigger the 'finished' logic to reset the UI
# This is safe because we just shut down all the threads
self.download_finished(0, 0, True, [])
def _get_domain_for_service(self, service_name: str) -> str:
"""Determines the base domain for a given service."""
@@ -5571,7 +5911,14 @@ class DownloaderApp (QWidget ):
'target_post_id_from_initial_url':None ,
'custom_folder_name':None ,
'num_file_threads':1 ,
# --- START: ADDED COOKIE FIX ---
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir,
# --- END: ADDED COOKIE FIX ---
'manga_date_file_counter_ref':None ,
}
@@ -5918,6 +6265,8 @@ class DownloaderApp (QWidget ):
self.is_fetching_only = False
self.fetched_posts_for_download = []
self.is_ready_to_download_fetched = False
self.fetched_posts_for_batch_update = []
self.is_ready_to_download_batch_update = False
self.allcomic_warning_shown = False
self.set_ui_enabled(True)
@@ -6270,21 +6619,16 @@ class DownloaderApp (QWidget ):
return
dialog = EmptyPopupDialog(self.app_base_dir, self)
if dialog.exec_() == QDialog.Accepted:
if dialog.update_profile_data:
self.active_update_profile = dialog.update_profile_data
self.link_input.setText(dialog.update_creator_name)
self.favorite_download_queue.clear()
if 'settings' in self.active_update_profile:
self.log_signal.emit(f" Applying saved settings from '{dialog.update_creator_name}' profile...")
self._load_ui_from_settings_dict(self.active_update_profile['settings'])
self.log_signal.emit(" Settings restored.")
# --- NEW BATCH UPDATE LOGIC ---
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
self.active_update_profiles_list = dialog.update_profiles_list
self.log_signal.emit(f" Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...")
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
self._start_batch_update_check(self.active_update_profiles_list)
self.log_signal.emit(f" Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
self._update_button_states_and_connections()
# --- Original logic for adding creators to queue ---
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None
self.active_update_profile = None # Ensure single update mode is off
self.favorite_download_queue.clear()
for creator_data in dialog.selected_creators_for_queue:
@@ -6314,9 +6658,7 @@ class DownloaderApp (QWidget ):
if hasattr(self, 'link_input'):
self.last_link_input_text_for_queue_sync = self.link_input.text()
# Manually trigger the UI update now that the queue is populated and the dialog is closed.
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
def _load_saved_cookie_settings(self):
"""Loads and applies saved cookie settings on startup."""
try: