diff --git a/Read.png b/Read.png index 85e3c9f..f468103 100644 Binary files a/Read.png and b/Read.png differ diff --git a/downloader_utils.py b/downloader_utils.py index 10bbfb4..698d556 100644 --- a/downloader_utils.py +++ b/downloader_utils.py @@ -62,8 +62,6 @@ VIDEO_EXTENSIONS = { ARCHIVE_EXTENSIONS = { '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2' } - -# --- Cookie Helper Functions --- def parse_cookie_string(cookie_string): """Parses a 'name=value; name2=value2' cookie string into a dict.""" cookies = {} @@ -88,13 +86,10 @@ def load_cookies_from_netscape_file(filepath, logger_func): continue parts = line.split('\t') if len(parts) == 7: - # Netscape format: domain, flag, path, secure, expiration, name, value name = parts[5] value = parts[6] if name: # Ensure name is not empty cookies[name] = value - # else: - # logger_func(f" đĒ Cookie file line {line_num} malformed (expected 7 tab-separated parts): '{line[:50]}...'") logger_func(f" đĒ Loaded {len(cookies)} cookies from '{os.path.basename(filepath)}'.") return cookies if cookies else None except FileNotFoundError: @@ -104,8 +99,6 @@ def load_cookies_from_netscape_file(filepath, logger_func): logger_func(f" đĒ Error parsing cookie file '{os.path.basename(filepath)}': {e}") return None -# --- End Cookie Helper Functions --- - def is_title_match_for_character(post_title, character_name_filter): if not post_title or not character_name_filter: return False @@ -137,15 +130,9 @@ def clean_folder_name(name): if not cleaned: # If empty after initial cleaning return "untitled_folder" - - # Strip all trailing dots and spaces. - # This handles cases like "folder...", "folder. .", "folder . ." -> "folder" temp_name = cleaned while len(temp_name) > 0 and (temp_name.endswith('.') or temp_name.endswith(' ')): temp_name = temp_name[:-1] - - # If stripping all trailing dots/spaces made it empty (e.g., original was "."), use default - # Also handles if the original name was just spaces and became empty. return temp_name if temp_name else "untitled_folder" @@ -158,10 +145,7 @@ def clean_filename(name): def strip_html_tags(html_text): if not html_text: return "" - # First, unescape HTML entities text = html.unescape(html_text) - # Then, remove HTML tags using a simple regex - # This is a basic approach and might not handle all complex HTML perfectly clean_pattern = re.compile('<.*?>') cleaned_text = re.sub(clean_pattern, '', text) return cleaned_text.strip() @@ -187,8 +171,6 @@ def match_folders_from_title(title, names_to_match, unwanted_keywords): if not title or not names_to_match: return [] title_lower = title.lower() matched_cleaned_names = set() - # Sort by the length of the primary name for matching longer, more specific names first. - # This is a heuristic; alias length might also be a factor but primary name length is simpler. sorted_name_objects = sorted(names_to_match, key=lambda x: len(x.get("name", "")), reverse=True) for name_obj in sorted_name_objects: @@ -625,7 +607,6 @@ class PostProcessorWorker: self.pause_event = pause_event # Store pause_event self.emitter = emitter # Store the emitter if not self.emitter: - # This case should ideally be prevented by the caller raise ValueError("PostProcessorWorker requires an emitter (signals object or queue).") self.skip_current_file_flag = skip_current_file_flag @@ -660,12 +641,9 @@ class PostProcessorWorker: if isinstance(self.emitter, queue.Queue): self.emitter.put({'type': signal_type_str, 'payload': payload_args}) elif self.emitter and hasattr(self.emitter, f"{signal_type_str}_signal"): - # Assuming emitter is a QObject with pyqtSignal attributes - # e.g., emitter.progress_signal.emit(*payload_args) signal_attr = getattr(self.emitter, f"{signal_type_str}_signal") signal_attr.emit(*payload_args) else: - # Fallback or error logging if emitter is not recognized print(f"(Worker Log - Unrecognized Emitter for {signal_type_str}): {payload_args[0] if payload_args else ''}") def logger(self, message): @@ -686,12 +664,10 @@ class PostProcessorWorker: return False # Not cancelled during pause def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event, # skip_event is threading.Event - # emitter_for_file_ops, # This will be self.emitter post_title="", file_index_in_post=0, num_files_in_this_post=1, manga_date_file_counter_ref=None): # Added manga_date_file_counter_ref was_original_name_kept_flag = False final_filename_saved_for_return = "" - # target_folder_path is the base character/post folder. def _get_current_character_filters(self): if self.dynamic_filter_holder: @@ -699,14 +675,12 @@ class PostProcessorWorker: 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, - # emitter_for_file_ops, # This will be self.emitter post_title="", file_index_in_post=0, num_files_in_this_post=1, # Added manga_date_file_counter_ref manga_date_file_counter_ref=None, forced_filename_override=None): # New for retries was_original_name_kept_flag = False final_filename_saved_for_return = "" retry_later_details = None # For storing info if retryable failure - # target_folder_path is the base character/post folder. if self._check_pause(f"File download prep for '{file_info.get('name', 'unknown file')}'"): return 0, 1, "", False if self.check_cancel() or (skip_event and skip_event.is_set()): return 0, 1, "", False @@ -716,14 +690,11 @@ class PostProcessorWorker: if self.use_cookie: # This flag comes from the checkbox cookies_to_use_for_file = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger) api_original_filename = file_info.get('_original_name_for_log', file_info.get('name')) - - # This is the ideal name for the file if it were to be saved in the main target_folder_path. filename_to_save_in_main_path = "" if forced_filename_override: filename_to_save_in_main_path = forced_filename_override self.logger(f" Retrying with forced filename: '{filename_to_save_in_main_path}'") - # was_original_name_kept_flag might need to be determined based on how forced_filename_override was created else: if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_FILES or self.skip_words_scope == SKIP_SCOPE_BOTH): filename_to_check_for_skip_words = api_original_filename.lower() @@ -755,21 +726,15 @@ class PostProcessorWorker: self.logger(f"â ī¸ Manga mode (Post Title Style): Post title missing for post {original_post_id_for_log}. Using cleaned original filename '{filename_to_save_in_main_path}'.") elif self.manga_filename_style == STYLE_DATE_BASED: current_thread_name = threading.current_thread().name - # self.logger(f"DEBUG_COUNTER [{current_thread_name}, PostID: {original_post_id_for_log}]: File '{api_original_filename}'. Manga Date Mode. Counter Ref ID: {id(manga_date_file_counter_ref)}, Value before access: {manga_date_file_counter_ref}") if manga_date_file_counter_ref is not None and len(manga_date_file_counter_ref) == 2: counter_val_for_filename = -1 counter_lock = manga_date_file_counter_ref[1] - - # self.logger(f"DEBUG_COUNTER [{current_thread_name}, PostID: {original_post_id_for_log}]: File '{api_original_filename}'. Attempting to acquire lock. Counter value before lock: {manga_date_file_counter_ref[0]}") with counter_lock: - # self.logger(f"DEBUG_COUNTER [{current_thread_name}, PostID: {original_post_id_for_log}]: File '{api_original_filename}'. Lock acquired. Counter value at lock acquisition: {manga_date_file_counter_ref[0]}") counter_val_for_filename = manga_date_file_counter_ref[0] manga_date_file_counter_ref[0] += 1 - # self.logger(f"DEBUG_COUNTER [{current_thread_name}, PostID: {original_post_id_for_log}]: File '{api_original_filename}'. Incremented counter. New counter value: {manga_date_file_counter_ref[0]}. Filename will use: {counter_val_for_filename}") filename_to_save_in_main_path = f"{counter_val_for_filename:03d}{original_ext}" - # self.logger(f"DEBUG_COUNTER [{current_thread_name}, PostID: {original_post_id_for_log}]: File '{api_original_filename}'. Lock released. Generated filename: {filename_to_save_in_main_path}") else: self.logger(f"â ī¸ Manga Date Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_date_file_counter_ref}") filename_to_save_in_main_path = clean_filename(api_original_filename) @@ -824,19 +789,11 @@ class PostProcessorWorker: if self.skip_rar and is_rar(api_original_filename): self.logger(f" -> Pref Skip: '{api_original_filename}' (RAR).") return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None - - # --- Pre-Download Duplicate Handling --- - # Skipping based on filename before download is removed to allow suffixing for files from different posts. - # Hash-based skipping occurs after download. - # Physical path existence is handled by suffixing logic later. - # Ensure base target folder exists (used for .part file with multipart) try: os.makedirs(target_folder_path, exist_ok=True) # For .part file except OSError as e: self.logger(f" â Critical error creating directory '{target_folder_path}': {e}. Skipping file '{api_original_filename}'.") return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None # Treat as skip - - # --- Download Attempt --- max_retries = 3 retry_delay = 5 downloaded_size_bytes = 0 @@ -869,8 +826,6 @@ class PostProcessorWorker: if attempt_multipart: response.close() self._emit_signal('file_download_status', False) - - # .part file is always based on the main target_folder_path and filename_to_save_in_main_path mp_save_path_base_for_part = os.path.join(target_folder_path, filename_to_save_in_main_path) mp_success, mp_bytes, mp_hash, mp_file_handle = download_file_in_parts( file_url, mp_save_path_base_for_part, total_size_bytes, num_parts_for_file, headers, api_original_filename, @@ -931,8 +886,6 @@ class PostProcessorWorker: if 'file_content_buffer' in locals() and file_content_buffer: file_content_buffer.close(); break finally: self._emit_signal('file_download_status', False) - - # Final progress update for single stream 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)) @@ -944,8 +897,6 @@ class PostProcessorWorker: if not download_successful_flag: self.logger(f"â Download failed for '{api_original_filename}' after {max_retries + 1} attempts.") if file_content_bytes: file_content_bytes.close() - - # Check if this failure is one we want to mark for later retry if isinstance(last_exception_for_retry_later, http.client.IncompleteRead): self.logger(f" Marking '{api_original_filename}' for potential retry later due to IncompleteRead.") retry_later_details = { @@ -964,43 +915,29 @@ class PostProcessorWorker: return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None # Generic failure 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 - # --- Universal Post-Download Hash Check --- 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) # Mark logical name if file_content_bytes: file_content_bytes.close() - # If it was a multipart download, its .part file needs cleanup if not isinstance(file_content_bytes, BytesIO): # Indicates multipart download part_file_to_remove = os.path.join(target_folder_path, filename_to_save_in_main_path + ".part") if os.path.exists(part_file_to_remove): try: os.remove(part_file_to_remove); except OSError: self.logger(f" -> Failed to remove .part file for hash duplicate: {part_file_to_remove}") # type: ignore return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None - - # --- Determine Save Location and Final Filename --- effective_save_folder = target_folder_path # Default: main character/post folder - # filename_to_save_in_main_path is the logical name after cleaning, manga styling, word removal filename_after_styling_and_word_removal = filename_to_save_in_main_path - - # "Move" logic and "Duplicate" subfolder logic removed. - # effective_save_folder will always be target_folder_path. try: # Ensure the chosen save folder (main or Duplicate) exists 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 file_content_bytes: file_content_bytes.close() - # Cleanup .part file if multipart if not isinstance(file_content_bytes, BytesIO): part_file_to_remove = os.path.join(target_folder_path, filename_to_save_in_main_path + ".part") if os.path.exists(part_file_to_remove): os.remove(part_file_to_remove) return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None - - # --- Image Compression --- - # This operates on file_content_bytes (which is BytesIO or a file handle from multipart) - # It might change filename_after_styling_and_word_removal's extension (e.g., .jpg to .webp) - # and returns new data_to_write_after_compression (BytesIO) or original file_content_bytes. data_to_write_after_compression = file_content_bytes filename_after_compression = filename_after_styling_and_word_removal @@ -1029,33 +966,21 @@ class PostProcessorWorker: except Exception as comp_e: self.logger(f"â Compression failed for '{api_original_filename}': {comp_e}. Saving original."); file_content_bytes.seek(0) data_to_write_after_compression = file_content_bytes # Use original - - # --- Final Numeric Suffixing in the effective_save_folder --- final_filename_on_disk = filename_after_compression # This is the name after potential compression - # If Manga Date Based style, we trust the counter from main.py. - # Suffixing should not be needed if the counter initialization was correct. - # If a file with the generated DDD.ext name exists, it will be overwritten. if not (self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED): temp_base, temp_ext = os.path.splitext(final_filename_on_disk) suffix_counter = 1 - # Check for existing file and apply suffix only if not in date-based manga mode 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: # Log if a suffix was applied self.logger(f" Applied numeric suffix in '{os.path.basename(effective_save_folder)}': '{final_filename_on_disk}' (was '{filename_after_compression}')") - # else: for STYLE_DATE_BASED, final_filename_on_disk remains 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 - # --- Save File --- final_save_path = os.path.join(effective_save_folder, final_filename_on_disk) try: - # data_to_write_after_compression is BytesIO (single stream, or compressed multipart) - # OR it's the original file_content_bytes (which is a file handle if uncompressed multipart) if data_to_write_after_compression is file_content_bytes and not isinstance(file_content_bytes, BytesIO): - # This means uncompressed multipart download. Original .part file handle is file_content_bytes. - # The .part file is at target_folder_path/filename_to_save_in_main_path.part original_part_file_actual_path = file_content_bytes.name file_content_bytes.close() # Close handle first os.rename(original_part_file_actual_path, final_save_path) @@ -1063,8 +988,6 @@ class PostProcessorWorker: else: # Single stream download, or compressed multipart. Write from BytesIO. with open(final_save_path, 'wb') as f_out: f_out.write(data_to_write_after_compression.getvalue()) - - # If original was multipart and then compressed, clean up original .part file if data_to_write_after_compression is not file_content_bytes and not isinstance(file_content_bytes, BytesIO): original_part_file_actual_path = file_content_bytes.name file_content_bytes.close() @@ -1074,10 +997,8 @@ class PostProcessorWorker: 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) # Track by logical name - # The counter for STYLE_DATE_BASED is now incremented *before* filename generation, under lock. 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)}'") - # Session-wide base name tracking removed. time.sleep(0.05) # Brief pause after successful save return 1, 0, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SUCCESS, None except Exception as save_err: @@ -1087,10 +1008,8 @@ class PostProcessorWorker: 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 # Treat save fail as skip finally: - # Ensure all handles are closed if data_to_write_after_compression and hasattr(data_to_write_after_compression, 'close'): data_to_write_after_compression.close() - # If original file_content_bytes was a different handle (e.g. multipart before compression) and not closed yet if file_content_bytes and file_content_bytes is not data_to_write_after_compression and hasattr(file_content_bytes, 'close'): try: if not file_content_bytes.closed: # Check if already closed @@ -1101,10 +1020,7 @@ class PostProcessorWorker: def process(self): if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"): return 0,0,[], [] if self.check_cancel(): return 0, 0, [], [] - - # Get the potentially updated character filters at the start of processing this post current_character_filters = self._get_current_character_filters() - # self.logger(f"DEBUG: Post {post_id}, Worker using filters: {[(f['name'], f['aliases']) for f in current_character_filters]}") kept_original_filenames_for_log = [] retryable_failures_this_post = [] # New list to store retryable failure details @@ -1132,40 +1048,30 @@ class PostProcessorWorker: post_is_candidate_by_title_char_match = False char_filter_that_matched_title = None post_is_candidate_by_comment_char_match = False - # New variables for CHAR_SCOPE_COMMENTS file-first logic post_is_candidate_by_file_char_match_in_comment_scope = False char_filter_that_matched_file_in_comment_scope = None char_filter_that_matched_comment = None if current_character_filters and \ (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH): - # self.logger(f" [Debug Title Match] Checking post title '{post_title}' against {len(self.filter_character_list_objects)} filter objects. Scope: {self.char_filter_scope}") if self._check_pause(f"Character title filter for post {post_id}"): return 0, num_potential_files_in_post, [], [] for idx, filter_item_obj in enumerate(current_character_filters): if self.check_cancel(): break - # self.logger(f" [Debug Title Match] Filter obj #{idx}: {filter_item_obj}") terms_to_check_for_title = list(filter_item_obj["aliases"]) if filter_item_obj["is_group"]: if filter_item_obj["name"] not in terms_to_check_for_title: terms_to_check_for_title.append(filter_item_obj["name"]) unique_terms_for_title_check = list(set(terms_to_check_for_title)) - # self.logger(f" [Debug Title Match] Unique terms for this filter obj: {unique_terms_for_title_check}") for term_to_match in unique_terms_for_title_check: - # self.logger(f" [Debug Title Match] Checking term: '{term_to_match}'") match_found_for_term = is_title_match_for_character(post_title, term_to_match) - # self.logger(f" [Debug Title Match] Result for '{term_to_match}': {match_found_for_term}") if match_found_for_term: post_is_candidate_by_title_char_match = True char_filter_that_matched_title = filter_item_obj self.logger(f" Post title matches char filter term '{term_to_match}' (from group/name '{filter_item_obj['name']}', Scope: {self.char_filter_scope}). Post is candidate.") break if post_is_candidate_by_title_char_match: break - # self.logger(f" [Debug Title Match] Final post_is_candidate_by_title_char_match: {post_is_candidate_by_title_char_match}") - - # --- Populate all_files_from_post_api before character filter logic that needs it --- - # This is needed for the file-first check in CHAR_SCOPE_COMMENTS all_files_from_post_api_for_char_check = [] api_file_domain_for_char_check = urlparse(self.api_url_input).netloc if not api_file_domain_for_char_check or not any(d in api_file_domain_for_char_check.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']): @@ -1181,7 +1087,6 @@ class PostProcessorWorker: original_api_att_name = att_info.get('name') or os.path.basename(att_info['path'].lstrip('/')) if original_api_att_name: all_files_from_post_api_for_char_check.append({'_original_name_for_log': original_api_att_name}) - # --- End population of all_files_from_post_api_for_char_check --- if current_character_filters and self.char_filter_scope == CHAR_SCOPE_COMMENTS: @@ -1258,8 +1163,6 @@ class PostProcessorWorker: self.logger(f" [Char Scope: Comments] Phase 2 Result: post_is_candidate_by_comment_char_match = {post_is_candidate_by_comment_char_match}") else: # post_is_candidate_by_file_char_match_in_comment_scope was True self.logger(f" [Char Scope: Comments] Phase 2: Skipped comment check for post ID '{post_id}' because a file match already made it a candidate.") - - # --- Skip Post Logic based on Title or Comment Scope (if filters are active) --- if current_character_filters: # Check if any filters are defined if self.char_filter_scope == CHAR_SCOPE_TITLE and not post_is_candidate_by_title_char_match: self.logger(f" -> Skip Post (Scope: Title - No Char Match): Title '{post_title[:50]}' does not match character filters.") @@ -1278,9 +1181,6 @@ class PostProcessorWorker: post_title_lower = post_title.lower() for skip_word in self.skip_words_list: if skip_word.lower() in post_title_lower: - # This is a skip by "skip_words_list", not by character filter. - # If you want these in the "Missed Character Log" too, you'd add a signal emit here. - # For now, sticking to the request for character filter misses. self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'. Scope: {self.skip_words_scope}") return 0, num_potential_files_in_post, [], [] @@ -1302,7 +1202,6 @@ class PostProcessorWorker: log_reason_for_folder = "" if self.char_filter_scope == CHAR_SCOPE_COMMENTS and char_filter_that_matched_comment: - # For CHAR_SCOPE_COMMENTS, prioritize file match for folder name if it happened if post_is_candidate_by_file_char_match_in_comment_scope and char_filter_that_matched_file_in_comment_scope: primary_char_filter_for_folder = char_filter_that_matched_file_in_comment_scope log_reason_for_folder = "Matched char filter in filename (Comments scope)" @@ -1312,25 +1211,18 @@ class PostProcessorWorker: elif (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and char_filter_that_matched_title: # Existing logic for other scopes primary_char_filter_for_folder = char_filter_that_matched_title log_reason_for_folder = "Matched char filter in title" - # If scope is FILES, primary_char_filter_for_folder will be None here. Folder determined per file. - - # When determining base_folder_names_for_post_content without a direct character filter match: if primary_char_filter_for_folder: base_folder_names_for_post_content = [clean_folder_name(primary_char_filter_for_folder["name"])] self.logger(f" Base folder name(s) for post content ({log_reason_for_folder}): {', '.join(base_folder_names_for_post_content)}") elif not current_character_filters: # No char filters defined, use generic logic derived_folders = match_folders_from_title(post_title, self.known_names, self.unwanted_keywords) if derived_folders: - # Use the live KNOWN_NAMES from downloader_utils for generic title parsing - # self.known_names is a snapshot from when the worker was created. base_folder_names_for_post_content.extend(match_folders_from_title(post_title, KNOWN_NAMES, self.unwanted_keywords)) else: base_folder_names_for_post_content.append(extract_folder_name_from_title(post_title, self.unwanted_keywords)) if not base_folder_names_for_post_content or not base_folder_names_for_post_content[0]: base_folder_names_for_post_content = [clean_folder_name(post_title if post_title else "untitled_creator_content")] self.logger(f" Base folder name(s) for post content (Generic title parsing - no char filters): {', '.join(base_folder_names_for_post_content)}") - # If char filters are defined, and scope is FILES, then base_folder_names_for_post_content remains empty. - # The folder will be determined by char_filter_info_that_matched_file later. if not self.extract_links_only and self.use_subfolders and self.skip_words_list: if self._check_pause(f"Folder keyword skip check for post {post_id}"): return 0, num_potential_files_in_post, [] @@ -1413,12 +1305,9 @@ class PostProcessorWorker: if not all_files_from_post_api: self.logger(f" -> No image thumbnails found for post {post_id} in thumbnail-only mode.") return 0, 0, [], [] - - # Sort files within the post by original name if in Date Based manga mode if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED: def natural_sort_key_for_files(file_api_info): name = file_api_info.get('_original_name_for_log', '').lower() - # Split into text and number parts for natural sorting (e.g., "file2.jpg" before "file10.jpg") return [int(text) if text.isdigit() else text for text in re.split('([0-9]+)', name)] all_files_from_post_api.sort(key=natural_sort_key_for_files) @@ -1489,12 +1378,10 @@ class PostProcessorWorker: char_filter_info_that_matched_file = char_filter_that_matched_title self.logger(f" File '{current_api_original_filename}' is candidate because post title matched. Scope: Both (Title part).") else: - # This part is for the "File" part of "Both" scope for filter_item_obj_both_file in current_character_filters: terms_to_check_for_file_both = list(filter_item_obj_both_file["aliases"]) if filter_item_obj_both_file["is_group"] and filter_item_obj_both_file["name"] not in terms_to_check_for_file_both: terms_to_check_for_file_both.append(filter_item_obj_both_file["name"]) - # Ensure unique_terms_for_file_both_check is defined here unique_terms_for_file_both_check = list(set(terms_to_check_for_file_both)) for term_to_match in unique_terms_for_file_both_check: @@ -1505,8 +1392,6 @@ class PostProcessorWorker: break if file_is_candidate_by_char_filter_scope: break elif self.char_filter_scope == CHAR_SCOPE_COMMENTS: - # If the post is a candidate (either by file or comment under this scope), then this file is also a candidate. - # The folder naming will use the filter that made the POST a candidate. if post_is_candidate_by_file_char_match_in_comment_scope: # Post was candidate due to a file match file_is_candidate_by_char_filter_scope = True char_filter_info_that_matched_file = char_filter_that_matched_file_in_comment_scope # Use the filter that matched a file in the post @@ -1577,8 +1462,6 @@ class PostProcessorWorker: except Exception as exc_f: self.logger(f"â File download task for post {post_id} resulted in error: {exc_f}") total_skipped_this_post += 1 - - # Clear file progress display after all files in a post are done self._emit_signal('file_progress', "", None) if self.check_cancel(): self.logger(f" Post {post_id} processing interrupted/cancelled."); @@ -1670,7 +1553,6 @@ class DownloadThread(QThread): self.cookie_text = cookie_text # Store cookie text self.use_cookie = use_cookie # Store cookie setting self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread - # self.manga_date_scan_dir = manga_date_scan_dir # Store scan directory if self.compress_images and Image is None: self.logger("â ī¸ Image compression disabled: Pillow library not found (DownloadThread).") self.compress_images = False @@ -1704,14 +1586,9 @@ class DownloadThread(QThread): grand_total_skipped_files = 0 grand_list_of_kept_original_filenames = [] was_process_cancelled = False - - # Initialize manga_date_file_counter_ref if needed (moved from main.py) - # This is now done within the DownloadThread's run method. current_manga_date_file_counter_ref = self.manga_date_file_counter_ref if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED and \ not self.extract_links_only and current_manga_date_file_counter_ref is None: # Check if it needs calculation - - # series_scan_directory calculation logic (simplified for direct use here) series_scan_dir = self.output_dir if self.use_subfolders: if self.filter_character_list_objects and self.filter_character_list_objects[0] and self.filter_character_list_objects[0].get("name"): @@ -1731,9 +1608,6 @@ class DownloadThread(QThread): if match: highest_num = max(highest_num, int(match.group(1))) current_manga_date_file_counter_ref = [highest_num + 1, threading.Lock()] self.logger(f"âšī¸ [Thread] Manga Date Mode: Initialized counter at {current_manga_date_file_counter_ref[0]}.") - - # This DownloadThread (being a QThread) will use its own signals object - # to communicate with PostProcessorWorker if needed. worker_signals_obj = PostProcessorSignals() try: worker_signals_obj.progress_signal.connect(self.progress_signal) @@ -1841,7 +1715,6 @@ class DownloadThread(QThread): worker_signals_obj.external_link_signal.disconnect(self.external_link_signal) worker_signals_obj.file_progress_signal.disconnect(self.file_progress_signal) worker_signals_obj.missed_character_post_signal.disconnect(self.missed_character_post_signal) - # No need to disconnect retryable_file_failed_signal from worker_signals_obj as it's not on it except (TypeError, RuntimeError) as e: self.logger(f"âšī¸ Note during DownloadThread signal disconnection: {e}") diff --git a/main.py b/main.py index 1e35a71..b96f73c 100644 --- a/main.py +++ b/main.py @@ -21,7 +21,8 @@ from PyQt5.QtGui import ( from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton, QButtonGroup, QCheckBox, QSplitter, - QDialog, QStackedWidget, QScrollArea, + QDialog, QStackedWidget, QScrollArea, + QAbstractItemView, # Added for QListWidget.NoSelection QFrame, QAbstractButton ) @@ -85,7 +86,6 @@ except Exception as e: MAX_THREADS = 200 RECOMMENDED_MAX_THREADS = 50 MAX_FILE_THREADS_PER_POST_OR_WORKER = 10 -# New constants for batching high thread counts for post workers POST_WORKER_BATCH_THRESHOLD = 30 POST_WORKER_NUM_BATCHES = 4 SOFT_WARNING_THREAD_THRESHOLD = 40 # New constant for soft warning @@ -106,9 +106,75 @@ ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1" USE_COOKIE_KEY = "useCookieV1" # New setting key COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1" -# CHAR_SCOPE_TITLE, CHAR_SCOPE_FILES, CHAR_SCOPE_BOTH, CHAR_SCOPE_COMMENTS are already defined or imported -# --- Tour Classes (Moved from tour.py) --- +# Custom dialog result constants for ConfirmAddAllDialog +CONFIRM_ADD_ALL_ACCEPTED = 1 +CONFIRM_ADD_ALL_SKIP_ADDING = 2 +CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 + +class ConfirmAddAllDialog(QDialog): + """A dialog to confirm adding multiple new names to Known.txt.""" + def __init__(self, new_names_list, parent=None): + super().__init__(parent) + self.setWindowTitle("Confirm Adding New Names") + self.setModal(True) + self.new_names_list = new_names_list + self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD # Default to cancel if closed + + main_layout = QVBoxLayout(self) + + info_label = QLabel( + "The following new names/groups from your 'Filter by Character(s)' input are not in 'Known.txt'.\n" + "Adding them can improve folder organization for future downloads.\n\n" + "Review the list and choose an action:" + ) + info_label.setWordWrap(True) + main_layout.addWidget(info_label) + + self.names_list_widget = QListWidget() + self.names_list_widget.addItems(self.new_names_list) + self.names_list_widget.setSelectionMode(QAbstractItemView.NoSelection) # Just for display + main_layout.addWidget(self.names_list_widget) + + buttons_layout = QHBoxLayout() + + self.add_all_button = QPushButton("Add All to Known.txt") + self.add_all_button.clicked.connect(self._accept_add_all) + buttons_layout.addWidget(self.add_all_button) + + self.skip_adding_button = QPushButton("Skip Adding These") + self.skip_adding_button.clicked.connect(self._reject_skip_adding) + buttons_layout.addWidget(self.skip_adding_button) + + buttons_layout.addStretch() + + self.cancel_download_button = QPushButton("Cancel Download") + self.cancel_download_button.clicked.connect(self._reject_cancel_download) + buttons_layout.addWidget(self.cancel_download_button) + + main_layout.addLayout(buttons_layout) + + self.setMinimumWidth(480) + self.setMinimumHeight(350) + if parent and hasattr(parent, 'get_dark_theme'): + self.setStyleSheet(parent.get_dark_theme()) + self.add_all_button.setDefault(True) + + def _accept_add_all(self): + self.user_choice = CONFIRM_ADD_ALL_ACCEPTED + self.accept() + + def _reject_skip_adding(self): + self.user_choice = CONFIRM_ADD_ALL_SKIP_ADDING + self.reject() # QDialog.reject() is fine, we check user_choice + + def _reject_cancel_download(self): + self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD + self.reject() # QDialog.reject() is fine, we check user_choice + + def exec_(self): + super().exec_() + return self.user_choice class TourStepWidget(QWidget): """A single step/page in the tour.""" def __init__(self, title_text, content_text, parent=None): @@ -119,11 +185,8 @@ class TourStepWidget(QWidget): title_label = QLabel(title_text) title_label.setAlignment(Qt.AlignCenter) - # Increased padding-bottom for more space below title title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;") layout.addWidget(title_label) - - # Create QScrollArea for content scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) # Important for the content_label to resize correctly scroll_area.setFrameShape(QFrame.NoFrame) # Make it look seamless with the dialog @@ -133,20 +196,12 @@ class TourStepWidget(QWidget): content_label = QLabel(content_text) content_label.setWordWrap(True) - # AlignTop ensures text starts from the top if it's shorter than the scroll area view content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) content_label.setTextFormat(Qt.RichText) - # Adjusted line-height for bullet point readability content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") - - # Set the content_label as the widget for the scroll_area scroll_area.setWidget(content_label) - - # Add the scroll_area to the layout, allowing it to take available space layout.addWidget(scroll_area, 1) # The '1' is a stretch factor - # Removed layout.addStretch(1) as the scroll_area now handles stretching. - class TourDialog(QDialog): """ @@ -168,7 +223,6 @@ class TourDialog(QDialog): self.setWindowTitle("Welcome to Kemono Downloader!") self.setModal(True) - # Set fixed square size, smaller than main window self.setFixedSize(600, 620) # Slightly adjusted for potentially more text self.setStyleSheet(""" QDialog { @@ -208,7 +262,6 @@ class TourDialog(QDialog): def _center_on_screen(self): """Centers the dialog on the screen.""" - # Updated to use availableGeometry and center more reliably try: primary_screen = QApplication.primaryScreen() if not primary_screen: @@ -233,8 +286,6 @@ class TourDialog(QDialog): self.stacked_widget = QStackedWidget() main_layout.addWidget(self.stacked_widget, 1) - - # --- Define Tour Steps with Updated Content --- step1_content = ( "Hello! This quick tour will walk you through the main features of the Kemono Downloader, including recent updates like enhanced filtering, manga mode improvements, and cookie management." "
([Power], powwr, pwr, Blood devil) ensures any post matching \"Power\", \"powwr\", etc. (based on your filter scope) gets saved into a \"Power\" folder. Simple entries like My Series are also supported. The primary name for the folder is the one in [] brackets, or the first one if no brackets.(Power, powwr, pwr, Blood devil) in Known.txt means any post matching \"Power\", \"powwr\", etc. (based on your filter scope) will be saved into a folder named \"Power powwr pwr Blood devil\" (after cleaning). Simple entries like My Series are also supported. The folder name for a group is derived from the *entire content* inside the parentheses, with commas replaced by spaces before cleaning.Known.txt to find a matching primary name for folder creation.tag with align attribute self.missed_character_log_output.append(f'
{display_term}
') self.missed_character_log_output.append(separator_line) self.missed_character_log_output.append("") # Add a blank line for spacing @@ -1717,8 +1672,6 @@ class DownloaderApp(QWidget): return total_downloaded_overall = sum(cs.get('downloaded', 0) for cs in progress_info) - # total_file_size_overall should ideally be from progress_data['total_file_size'] - # For now, we sum chunk totals. This assumes all chunks are for the same file. total_file_size_overall = sum(cs.get('total', 0) for cs in progress_info) active_chunks_count = 0 @@ -1795,14 +1748,10 @@ class DownloaderApp(QWidget): filter_mode_text = button.text() is_only_links = (filter_mode_text == "đ Only Links") is_only_archives = (filter_mode_text == "đĻ Only Archives") - - # --- Visibility for log header buttons --- - # Hide these buttons if in "Only Links" or "Only Archives" mode if self.skip_scope_toggle_button: self.skip_scope_toggle_button.setVisible(not (is_only_links or is_only_archives)) if hasattr(self, 'multipart_toggle_button') and self.multipart_toggle_button: self.multipart_toggle_button.setVisible(not (is_only_links or is_only_archives)) - # Other log header buttons (manga, char filter scope) are handled by update_ui_for_manga_mode and update_ui_for_subfolders if self.link_search_input: self.link_search_input.setVisible(is_only_links) if self.link_search_button: self.link_search_button.setVisible(is_only_links) @@ -1885,7 +1834,6 @@ class DownloaderApp(QWidget): self.update_ui_for_subfolders(subfolders_on) self.update_custom_folder_visibility() - # Ensure manga mode UI updates (which includes the visibility of manga_rename_toggle_button) self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False) @@ -2086,7 +2034,6 @@ class DownloaderApp(QWidget): ) def _cycle_char_filter_scope(self): - # Cycle: Files -> Title -> Both -> Comments -> Files if self.char_filter_scope == CHAR_SCOPE_FILES: self.char_filter_scope = CHAR_SCOPE_TITLE elif self.char_filter_scope == CHAR_SCOPE_TITLE: @@ -2102,65 +2049,82 @@ class DownloaderApp(QWidget): self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope) self.log_signal.emit(f"âšī¸ Character filter scope changed to: '{self.char_filter_scope}'") + def _handle_ui_add_new_character(self): + """Handles adding a new character from the UI input field.""" + name_from_ui_input = self.new_char_input.text().strip() + if not name_from_ui_input: + QMessageBox.warning(self, "Input Error", "Name cannot be empty.") + return + # For UI additions, it's always a simple, non-group entry. + # The special ( ) and ( )~ parsing is for the "Filter by Character(s)" field. + self.add_new_character(name_to_add=name_from_ui_input, + is_group_to_add=False, + aliases_to_add=[name_from_ui_input], + suppress_similarity_prompt=False) # UI adds one by one, so prompt is fine - def add_new_character(self): + def add_new_character(self, name_to_add, is_group_to_add, aliases_to_add, suppress_similarity_prompt=False): global KNOWN_NAMES, clean_folder_name - name_to_add = self.new_char_input.text().strip() if not name_to_add: - QMessageBox.warning(self, "Input Error", "Name cannot be empty."); return False + QMessageBox.warning(self, "Input Error", "Name cannot be empty."); return False # Return False on failure name_to_add_lower = name_to_add.lower() - - # Check for duplicates (primary name or any alias) for kn_entry in KNOWN_NAMES: if kn_entry["name"].lower() == name_to_add_lower: - QMessageBox.warning(self, "Duplicate Name", f"The name '{name_to_add}' already exists as a primary folder name."); return False - for alias in kn_entry["aliases"]: - if alias.lower() == name_to_add_lower: - QMessageBox.warning(self, "Duplicate Alias", f"The name '{name_to_add}' already exists as an alias for '{kn_entry['name']}'."); return False - + QMessageBox.warning(self, "Duplicate Name", f"The primary folder name '{name_to_add}' already exists."); return False + if not is_group_to_add and name_to_add_lower in [a.lower() for a in kn_entry["aliases"]]: # Check if new simple name is an alias elsewhere + QMessageBox.warning(self, "Duplicate Alias", f"The name '{name_to_add}' already exists as an alias for '{kn_entry['name']}'."); return False + similar_names_details = [] - # Check for similarity with existing primary names or aliases for kn_entry in KNOWN_NAMES: for term_to_check_similarity_against in kn_entry["aliases"]: # Check against all aliases term_lower = term_to_check_similarity_against.lower() if name_to_add_lower != term_lower and \ (name_to_add_lower in term_lower or term_lower in name_to_add_lower): - # Warn about similarity with the primary name of the group similar_names_details.append((name_to_add, kn_entry["name"])) + break + # Also check if any of the new aliases are similar to existing primary names or other aliases + for new_alias in aliases_to_add: + if new_alias.lower() != term_to_check_similarity_against.lower() and (new_alias.lower() in term_to_check_similarity_against.lower() or term_to_check_similarity_against.lower() in new_alias.lower()): + similar_names_details.append((new_alias, kn_entry["name"])) break # Found a similarity for this entry, no need to check its other aliases - if similar_names_details: - first_similar_new, first_similar_existing = similar_names_details[0] - shorter, longer = sorted([first_similar_new, first_similar_existing], key=len) + if similar_names_details and not suppress_similarity_prompt: + # This block is only entered if suppress_similarity_prompt is False + # and there are similar names. + # If suppress_similarity_prompt is True, this entire block is skipped. + if similar_names_details: # Double check, though outer if should cover + first_similar_new, first_similar_existing = similar_names_details[0] + shorter, longer = sorted([first_similar_new, first_similar_existing], key=len) - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Warning) - msg_box.setWindowTitle("Potential Name Conflict") - msg_box.setText( - f"The name '{first_similar_new}' is very similar to an existing name: '{first_similar_existing}'.\n\n" - f"This could lead to unexpected folder grouping (e.g., under '{clean_folder_name(shorter)}' instead of a more specific '{clean_folder_name(longer)}' or vice-versa).\n\n" - "Do you want to change the name you are adding, or proceed anyway?" - ) - change_button = msg_box.addButton("Change Name", QMessageBox.RejectRole) - proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) - msg_box.setDefaultButton(proceed_button) - msg_box.setEscapeButton(change_button) - msg_box.exec_() + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Potential Name Conflict") + msg_box.setText( + f"The name '{first_similar_new}' is very similar to an existing name: '{first_similar_existing}'.\n\n" + f"This could lead to unexpected folder grouping (e.g., under '{clean_folder_name(shorter)}' instead of a more specific '{clean_folder_name(longer)}' or vice-versa).\n\n" + "Do you want to change the name you are adding, or proceed anyway?" + ) + change_button = msg_box.addButton("Change Name", QMessageBox.RejectRole) + proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) + msg_box.setDefaultButton(proceed_button) + msg_box.setEscapeButton(change_button) + msg_box.exec_() - if msg_box.clickedButton() == change_button: - self.log_signal.emit(f"âšī¸ User chose to change '{first_similar_new}' due to similarity with an alias of '{first_similar_existing_primary}'.") - return False - - self.log_signal.emit(f"â ī¸ User proceeded with adding '{first_similar_new}' despite similarity with an alias of '{first_similar_existing_primary}'.") - - # Add as a simple (non-group) entry + if msg_box.clickedButton() == change_button: + self.log_signal.emit(f"âšī¸ User chose to change '{first_similar_new}' due to similarity with an alias of '{first_similar_existing}'.") + return False # Return False on failure/user cancel + self.log_signal.emit(f"â ī¸ User proceeded with adding '{first_similar_new}' despite similarity with an alias of '{first_similar_existing}'.") new_entry = { - "name": name_to_add, - "is_group": False, - "aliases": [name_to_add] + "name": name_to_add, # This is the primary/folder name + "is_group": is_group_to_add, + "aliases": sorted(list(set(aliases_to_add)), key=str.lower) # Ensure unique and sorted aliases } + # Final check for alias conflicts if this is a group + if is_group_to_add: + for new_alias in new_entry["aliases"]: + if any(new_alias.lower() == kn_entry["name"].lower() for kn_entry in KNOWN_NAMES if kn_entry["name"].lower() != name_to_add_lower): + QMessageBox.warning(self, "Alias Conflict", f"Alias '{new_alias}' (for group '{name_to_add}') conflicts with an existing primary name."); return False KNOWN_NAMES.append(new_entry) KNOWN_NAMES.sort(key=lambda x: x["name"].lower()) # Sort by primary name @@ -2168,7 +2132,8 @@ class DownloaderApp(QWidget): self.character_list.addItems([entry["name"] for entry in KNOWN_NAMES]) self.filter_character_list(self.character_search_input.text()) - self.log_signal.emit(f"â Added '{name_to_add}' to known names list.") + log_msg_suffix = f" (as group with aliases: {', '.join(new_entry['aliases'])})" if is_group_to_add and len(new_entry['aliases']) > 1 else "" + self.log_signal.emit(f"â Added '{name_to_add}' to known names list{log_msg_suffix}.") self.new_char_input.clear() self.save_known_names() return True @@ -2253,8 +2218,6 @@ class DownloaderApp(QWidget): if cookie_text_input_exists or cookie_browse_button_exists: is_only_links = self.radio_only_links and self.radio_only_links.isChecked() - - # Cookie text input and browse button are visible if "Use Cookie" is checked if cookie_text_input_exists: self.cookie_text_input.setVisible(checked) if cookie_browse_button_exists: self.cookie_browse_button.setVisible(checked) @@ -2262,8 +2225,6 @@ class DownloaderApp(QWidget): enable_state_for_fields = can_enable_cookie_text and (self.download_btn.isEnabled() or self.is_paused) if cookie_text_input_exists: - # Text input is always enabled if its parent "Use Cookie" is checked and conditions met, - # unless a file path is displayed (then it's read-only). self.cookie_text_input.setEnabled(enable_state_for_fields) if self.selected_cookie_filepath and checked: # If a file is selected and "Use Cookie" is on self.cookie_text_input.setText(self.selected_cookie_filepath) @@ -2347,15 +2308,6 @@ class DownloaderApp(QWidget): if current_style == STYLE_POST_TITLE: # Title -> Original new_style = STYLE_ORIGINAL_NAME - # The warning for original name style - # reply = QMessageBox.information(self, "Manga Filename Preference", - # "Using 'Name: Post Title' (first file by title, others original) is recommended for Manga Mode.\n\n" - # "Using 'Name: Original File' for all files might lead to less organized downloads if original names are inconsistent or non-sequential.\n\n" - # "Proceed with using 'Name: Original File' for all files?", - # QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - # if reply == QMessageBox.No: - # self.log_signal.emit("âšī¸ Manga filename style change to 'Original File' cancelled by user.") - # return elif current_style == STYLE_ORIGINAL_NAME: # Original -> Date new_style = STYLE_DATE_BASED elif current_style == STYLE_DATE_BASED: # Date -> Title @@ -2373,7 +2325,6 @@ class DownloaderApp(QWidget): def update_ui_for_manga_mode(self, checked): - # Get current filter mode status is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked() is_only_archives_mode = self.radio_only_archives and self.radio_only_archives.isChecked() @@ -2381,19 +2332,15 @@ class DownloaderApp(QWidget): _, _, post_id = extract_post_info(url_text) is_creator_feed = not post_id if url_text else False - - # Manga mode checkbox itself is only enabled for creator feeds if self.manga_mode_checkbox: self.manga_mode_checkbox.setEnabled(is_creator_feed) if not is_creator_feed and self.manga_mode_checkbox.isChecked(): - # If URL changes to non-creator feed, uncheck manga mode self.manga_mode_checkbox.setChecked(False) checked = self.manga_mode_checkbox.isChecked() manga_mode_effectively_on = is_creator_feed and checked if self.manga_rename_toggle_button: - # Visible if manga mode is on AND not in "Only Links" or "Only Archives" mode self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode)) @@ -2459,7 +2406,6 @@ class DownloaderApp(QWidget): if manga_on and is_date_style: if self.use_multithreading_checkbox.isChecked() or self.use_multithreading_checkbox.isEnabled(): - # Only log if a change is made or it was previously enabled if self.use_multithreading_checkbox.isChecked(): self.log_signal.emit("âšī¸ Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.") self.use_multithreading_checkbox.setChecked(False) @@ -2499,7 +2445,6 @@ class DownloaderApp(QWidget): use_multithreading_enabled_by_checkbox = self.use_multithreading_checkbox.isChecked() try: - # num_threads_from_gui is used for post workers or file workers depending on context num_threads_from_gui = int(self.thread_count_input.text().strip()) if num_threads_from_gui < 1: num_threads_from_gui = 1 except ValueError: @@ -2508,7 +2453,6 @@ class DownloaderApp(QWidget): return if use_multithreading_enabled_by_checkbox: - # Hard Warning: Threads > MAX_THREADS (200) if num_threads_from_gui > MAX_THREADS: hard_warning_msg = ( f"You've entered a thread count ({num_threads_from_gui}) exceeding the maximum of {MAX_THREADS}.\n\n" @@ -2522,9 +2466,6 @@ class DownloaderApp(QWidget): num_threads_from_gui = MAX_THREADS self.thread_count_input.setText(str(MAX_THREADS)) # Update the input field self.log_signal.emit(f"â ī¸ User attempted {num_threads_from_gui} threads, capped to {MAX_THREADS}.") - - # Soft Warning: SOFT_WARNING_THREAD_THRESHOLD < Threads <= MAX_THREADS - # This uses the potentially capped num_threads_from_gui from the hard warning if SOFT_WARNING_THREAD_THRESHOLD < num_threads_from_gui <= MAX_THREADS: soft_warning_msg_box = QMessageBox(self) soft_warning_msg_box.setIcon(QMessageBox.Question) @@ -2567,8 +2508,6 @@ class DownloaderApp(QWidget): extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked()) backend_filter_mode = self.get_filter_mode() user_selected_filter_text = self.radio_group.checkedButton().text() if self.radio_group.checkedButton() else "All" - - # If a file path is selected, cookie_text_from_input should be considered empty for backend logic if selected_cookie_file_path_for_backend: cookie_text_from_input = "" @@ -2616,89 +2555,77 @@ class DownloaderApp(QWidget): except ValueError as e: QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}"); return elif manga_mode: start_page, end_page = None, None - - # Manga Mode specific duplicate handling is now managed entirely within downloader_utils.py self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None raw_character_filters_text = self.character_input.text().strip() # Get current text parsed_character_filter_objects = self._parse_character_filters(raw_character_filters_text) # Parse it - filter_character_list_to_pass = None + # This will be the list of filter objects passed to the backend + actual_filters_to_use_for_run = [] + needs_folder_naming_validation = (use_subfolders or manga_mode) and not extract_links_only - if parsed_character_filter_objects and not extract_links_only : - self.log_signal.emit(f"âšī¸ Validating character filters: {', '.join(item['name'] + (' (Group: ' + '/'.join(item['aliases']) + ')' if item['is_group'] else '') for item in parsed_character_filter_objects)}") - valid_filters_for_backend = [] - user_cancelled_validation = False + if parsed_character_filter_objects: + actual_filters_to_use_for_run = parsed_character_filter_objects # Use all parsed filters for matching - for filter_item_obj in parsed_character_filter_objects: - item_primary_name = filter_item_obj["name"] - cleaned_name_test = clean_folder_name(item_primary_name) + if not extract_links_only: + self.log_signal.emit(f"âšī¸ Using character filters for matching: {', '.join(item['name'] for item in actual_filters_to_use_for_run)}") + # --- Logic for Known.txt prompting (does not change filters for current run) --- + filter_objects_to_potentially_add_to_known_list = [] + for filter_item_obj in parsed_character_filter_objects: # Iterate over the same parsed_character_filter_objects + item_primary_name = filter_item_obj["name"] + # Check for folder name validity only for the purpose of Known.txt interaction + cleaned_name_test = clean_folder_name(item_primary_name) + if needs_folder_naming_validation and not cleaned_name_test: + QMessageBox.warning(self, "Invalid Filter Name for Folder", f"Filter name '{item_primary_name}' is invalid for a folder and will be skipped for Known.txt interaction.") + self.log_signal.emit(f"â ī¸ Skipping invalid filter for Known.txt interaction: '{item_primary_name}'") + continue # Skip this filter for Known.txt prompting + + an_alias_is_already_known = False + if any(kn_entry["name"].lower() == item_primary_name.lower() for kn_entry in KNOWN_NAMES): + an_alias_is_already_known = True + elif filter_item_obj["is_group"] and needs_folder_naming_validation: + for alias_in_filter_obj in filter_item_obj["aliases"]: + if any(kn_entry["name"].lower() == alias_in_filter_obj.lower() or alias_in_filter_obj.lower() in [a.lower() for a in kn_entry["aliases"]] for kn_entry in KNOWN_NAMES): + an_alias_is_already_known = True; break + + if an_alias_is_already_known and filter_item_obj["is_group"]: + self.log_signal.emit(f"âšī¸ An alias from group '{item_primary_name}' is already known. Group will not be prompted for Known.txt addition.") - if needs_folder_naming_validation and not cleaned_name_test: - QMessageBox.warning(self, "Invalid Filter Name for Folder", f"Filter name '{item_primary_name}' is invalid for a folder and will be skipped for folder naming.") - self.log_signal.emit(f"â ī¸ Skipping invalid filter for folder naming: '{item_primary_name}'") - continue - - # --- New: Check if any alias of a group is already known --- - an_alias_is_already_known = False - if filter_item_obj["is_group"] and needs_folder_naming_validation: - for alias in filter_item_obj["aliases"]: - if any(existing_known.lower() == alias.lower() for existing_known in KNOWN_NAMES): - an_alias_is_already_known = True - self.log_signal.emit(f"âšī¸ Alias '{alias}' (from group '{item_primary_name}') is already in Known Names. Group name '{item_primary_name}' will not be added to Known.txt.") - break - # --- End new check --- - - if an_alias_is_already_known: - valid_filters_for_backend.append(filter_item_obj) - continue - - # Determine if we should prompt to add the name to the Known.txt list. - # Prompt if: - # - Folder naming validation is relevant (subfolders or manga mode, and not just extracting links) - # - AND Manga Mode is OFF (this is the key change for your request) - # - AND the primary name of the filter isn't already in Known.txt - should_prompt_to_add_to_known_list = ( - needs_folder_naming_validation and - not manga_mode and # Do NOT prompt if Manga Mode is ON - item_primary_name.lower() not in {kn_entry["name"].lower() for kn_entry in KNOWN_NAMES} - ) - - if should_prompt_to_add_to_known_list: - reply = QMessageBox.question(self, "Add to Known List?", - f"Filter name '{item_primary_name}' (used for folder/manga naming) is not in known names list.\nAdd it now?", - QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes) - if reply == QMessageBox.Yes: - self.new_char_input.setText(item_primary_name) # Use the primary name for adding - if self.add_new_character(): - valid_filters_for_backend.append(filter_item_obj) - elif reply == QMessageBox.Cancel: - user_cancelled_validation = True; break - # If 'No', the filter is not used and not added to Known.txt for this session. - else: - # Add to filters to be used for this session if: - # - Prompting is not needed (e.g., name already known, or not manga_mode but name is known) - # - OR Manga Mode is ON (filter is used without adding to Known.txt) - # - OR extract_links_only is true (folder naming validation is false) - valid_filters_for_backend.append(filter_item_obj) - if manga_mode and needs_folder_naming_validation and item_primary_name.lower() not in {kn_entry["name"].lower() for kn_entry in KNOWN_NAMES}: + should_prompt_to_add_to_known_list = ( + needs_folder_naming_validation and not manga_mode and + not any(kn_entry["name"].lower() == item_primary_name.lower() for kn_entry in KNOWN_NAMES) and + not an_alias_is_already_known + ) + if should_prompt_to_add_to_known_list: + if not any(obj_to_add["name"].lower() == item_primary_name.lower() for obj_to_add in filter_objects_to_potentially_add_to_known_list): + filter_objects_to_potentially_add_to_known_list.append(filter_item_obj) + elif manga_mode and needs_folder_naming_validation and item_primary_name.lower() not in {kn_entry["name"].lower() for kn_entry in KNOWN_NAMES} and not an_alias_is_already_known: self.log_signal.emit(f"âšī¸ Manga Mode: Using filter '{item_primary_name}' for this session without adding to Known Names.") - if user_cancelled_validation: return - - if valid_filters_for_backend: - filter_character_list_to_pass = valid_filters_for_backend - self.log_signal.emit(f" Using validated character filters: {', '.join(item['name'] for item in filter_character_list_to_pass)}") - else: - self.log_signal.emit("â ī¸ No valid character filters to use for this session.") - elif parsed_character_filter_objects : # If not extract_links_only is false, but filters exist - filter_character_list_to_pass = parsed_character_filter_objects - self.log_signal.emit(f"âšī¸ Character filters provided (folder naming validation may not apply): {', '.join(item['name'] for item in filter_character_list_to_pass)}") + if filter_objects_to_potentially_add_to_known_list: + display_names_for_dialog = [obj["name"] for obj in filter_objects_to_potentially_add_to_known_list] + confirm_dialog = ConfirmAddAllDialog(display_names_for_dialog, self) + dialog_result = confirm_dialog.exec_() + if dialog_result == CONFIRM_ADD_ALL_CANCEL_DOWNLOAD: + self.log_signal.emit("â Download cancelled by user at new name confirmation stage.") + self.set_ui_enabled(True); return + elif dialog_result == CONFIRM_ADD_ALL_ACCEPTED: + self.log_signal.emit(f"âšī¸ User chose to add {len(filter_objects_to_potentially_add_to_known_list)} new entry/entries to Known.txt.") + for filter_obj_to_add in filter_objects_to_potentially_add_to_known_list: + self.add_new_character(name_to_add=filter_obj_to_add["name"], is_group_to_add=filter_obj_to_add["is_group"], aliases_to_add=filter_obj_to_add["aliases"], suppress_similarity_prompt=True) + elif dialog_result == CONFIRM_ADD_ALL_SKIP_ADDING: + self.log_signal.emit("âšī¸ User chose not to add new names to Known.txt for this session.") + # --- End of Known.txt prompting logic --- + else: # extract_links_only is true + self.log_signal.emit(f"âšī¸ Using character filters for link extraction: {', '.join(item['name'] for item in actual_filters_to_use_for_run)}") + else: # No character filters typed by user + self.log_signal.emit("âšī¸ No character filters provided. All content (matching other criteria) will be processed.") + # actual_filters_to_use_for_run remains [] - if manga_mode and not filter_character_list_to_pass and not extract_links_only: + if manga_mode and not actual_filters_to_use_for_run and not extract_links_only: msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Warning) msg_box.setWindowTitle("Manga Mode Filter Warning") @@ -2715,10 +2642,7 @@ class DownloaderApp(QWidget): self.log_signal.emit("â Download cancelled due to Manga Mode filter warning."); return else: self.log_signal.emit("â ī¸ Proceeding with Manga Mode without a specific title filter.") - - # Set the dynamic filter holder with the filters determined for this run - # This ensures workers get the correct initial set if they start before any live changes. - self.dynamic_character_filter_holder.set_filters(filter_character_list_to_pass if filter_character_list_to_pass else []) + self.dynamic_character_filter_holder.set_filters(actual_filters_to_use_for_run) custom_folder_name_cleaned = None if use_subfolders and post_id_from_url and self.custom_folder_widget and self.custom_folder_widget.isVisible() and not extract_links_only: @@ -2742,38 +2666,23 @@ class DownloaderApp(QWidget): self.progress_label.setText("Progress: Initializing...") self.retryable_failed_files_info.clear() # Clear previous retryable failures before new session - # Manga date file counter initialization is now moved into DownloadThread.run() - # We will pass None or a placeholder if needed, and DownloadThread will calculate it. manga_date_file_counter_ref_for_thread = None if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not extract_links_only: - # Pass None; DownloadThread will calculate if it's a single-threaded download. - # For multi-threaded, this ref needs to be created here and shared. - # However, with date_based manga mode forcing single post worker, this specific ref might only be used by that one worker. - # Let's keep it as None for now, assuming DownloadThread handles its init if it's the one doing sequential processing. - # If multi-threaded post processing were allowed with date-based, this would need careful shared state. manga_date_file_counter_ref_for_thread = None self.log_signal.emit(f"âšī¸ Manga Date Mode: File counter will be initialized by the download thread.") effective_num_post_workers = 1 effective_num_file_threads_per_worker = 1 # Default to 1 for all cases initially if post_id_from_url: - # Single post URL: UI threads control concurrent file downloads for that post if use_multithreading_enabled_by_checkbox: effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER)) - # else: effective_num_file_threads_per_worker remains 1 - # effective_num_post_workers remains 1 (not used for post thread pool) else: - # Creator feed URL if manga_mode and self.manga_filename_style == STYLE_DATE_BASED: - # Force single post worker for date-based manga mode effective_num_post_workers = 1 - # File threads per worker can still be > 1 if user set it effective_num_file_threads_per_worker = 1 # Files are sequential for this worker too elif use_multithreading_enabled_by_checkbox: # Standard creator feed with multithreading enabled effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) # For posts effective_num_file_threads_per_worker = 1 # Files within each post worker are sequential - # else (not multithreading for creator feed): - # effective_num_post_workers remains 1, effective_num_file_threads_per_worker remains 1 log_messages = ["="*40, f"đ Starting {'Link Extraction' if extract_links_only else ('Archive Download' if backend_filter_mode == 'archive' else 'Download')} @ {time.strftime('%Y-%m-%d %H:%M:%S')}", f" URL: {api_url}"] if not extract_links_only: log_messages.append(f" Save Location: {output_dir}") @@ -2798,8 +2707,8 @@ class DownloaderApp(QWidget): log_messages.append(f" Subfolders: {'Enabled' if use_subfolders else 'Disabled'}") if use_subfolders: if custom_folder_name_cleaned: log_messages.append(f" Custom Folder (Post): '{custom_folder_name_cleaned}'") - if filter_character_list_to_pass: - log_messages.append(f" Character Filters: {', '.join(item['name'] for item in filter_character_list_to_pass)}") + if actual_filters_to_use_for_run: + log_messages.append(f" Character Filters: {', '.join(item['name'] for item in actual_filters_to_use_for_run)}") log_messages.append(f" âŗ Char Filter Scope: {current_char_filter_scope.capitalize()}") elif use_subfolders: log_messages.append(f" Folder Naming: Automatic (based on title/known names)") @@ -2815,7 +2724,6 @@ class DownloaderApp(QWidget): f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}" # Removed duplicate file handling log ]) else: - # If only_links, cookie might still be relevant for accessing the page log_messages.append(f" Mode: Extracting Links Only") log_messages.append(f" Show External Links: {'Enabled' if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive' else 'Disabled'}") @@ -2823,8 +2731,8 @@ class DownloaderApp(QWidget): if manga_mode: log_messages.append(f" Manga Mode (File Renaming by Post Title): Enabled") log_messages.append(f" âŗ Manga Filename Style: {'Post Title Based' if self.manga_filename_style == STYLE_POST_TITLE else 'Original File Name'}") - if filter_character_list_to_pass: - log_messages.append(f" âŗ Manga Character Filter (for naming/folder): {', '.join(item['name'] for item in filter_character_list_to_pass)}") + if actual_filters_to_use_for_run: + log_messages.append(f" âŗ Manga Character Filter (for naming/folder): {', '.join(item['name'] for item in actual_filters_to_use_for_run)}") log_messages.append(f" âŗ Manga Duplicates: Will be renamed with numeric suffix if names clash (e.g., _1, _2).") log_messages.append(f" Use Cookie ('cookies.txt'): {'Enabled' if use_cookie_from_checkbox else 'Disabled'}") @@ -2833,7 +2741,6 @@ class DownloaderApp(QWidget): elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend: log_messages.append(f" âŗ Cookie File Selected: {os.path.basename(selected_cookie_file_path_for_backend)}") should_use_multithreading_for_posts = use_multithreading_enabled_by_checkbox and not post_id_from_url - # Adjust log message if date-based manga mode forced single thread if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not post_id_from_url: log_messages.append(f" Threading: Single-threaded (posts) - Enforced by Manga Date Mode") should_use_multithreading_for_posts = False # Ensure this reflects the forced state @@ -2853,8 +2760,8 @@ class DownloaderApp(QWidget): 'download_root': output_dir, 'output_dir': output_dir, 'known_names': list(KNOWN_NAMES), - 'known_names_copy': list(KNOWN_NAMES), - 'filter_character_list': filter_character_list_to_pass, + 'known_names_copy': list(KNOWN_NAMES), # Used by DownloadThread constructor + 'filter_character_list': actual_filters_to_use_for_run, # Pass the correctly determined list 'filter_mode': backend_filter_mode, 'skip_zip': effective_skip_zip, 'skip_rar': effective_skip_rar, @@ -2883,7 +2790,6 @@ class DownloaderApp(QWidget): 'cancellation_event': self.cancellation_event, 'dynamic_character_filter_holder': self.dynamic_character_filter_holder, # Pass the holder 'pause_event': self.pause_event, # Explicitly add pause_event here - # 'emitter' will be set based on single/multi-thread mode below 'manga_filename_style': self.manga_filename_style, 'num_file_threads_for_worker': effective_num_file_threads_per_worker, 'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread, @@ -2892,7 +2798,6 @@ class DownloaderApp(QWidget): 'selected_cookie_file': selected_cookie_file_path_for_backend, # Pass selected cookie file 'app_base_dir': app_base_dir_for_cookies, # Pass app base dir 'use_cookie': use_cookie_from_checkbox, # Pass cookie setting - # 'duplicate_file_mode' and session-wide tracking removed } try: @@ -2902,7 +2807,6 @@ class DownloaderApp(QWidget): self.start_multi_threaded_download(num_post_workers=effective_num_post_workers, **args_template) else: self.log_signal.emit(f" Initializing single-threaded {'link extraction' if extract_links_only else 'download'}...") - # For single-threaded, DownloadThread creates its own PostProcessorSignals and passes it as emitter. dt_expected_keys = [ 'api_url_input', 'output_dir', 'known_names_copy', 'cancellation_event', 'filter_character_list', 'filter_mode', 'skip_zip', 'skip_rar', @@ -2962,9 +2866,6 @@ class DownloaderApp(QWidget): global PostProcessorWorker # Ensure PostProcessorWorker is accessible if not isinstance(post_data_item, dict): self.log_signal.emit(f"â ī¸ Skipping invalid post data item (not a dict): {type(post_data_item)}"); - # Note: This skip does not directly increment processed_posts_count here, - # as that counter is tied to future completion. - # The overall effect is that total_posts_to_process might be higher than actual futures. return False # Indicate failure or skip worker_init_args = {} @@ -3032,8 +2933,6 @@ class DownloaderApp(QWidget): all_posts_data = [] fetch_error_occurred = False manga_mode_active_for_fetch = worker_args_template.get('manga_mode_active', False) - - # In multi-threaded mode, the emitter is the queue. emitter_for_worker = worker_args_template.get('emitter') # This should be self.worker_to_gui_queue if not emitter_for_worker: # Should not happen if logic in start_download is correct self.log_signal.emit("â CRITICAL ERROR: Emitter (queue) missing for worker in _fetch_and_queue_posts."); @@ -3064,20 +2963,16 @@ class DownloaderApp(QWidget): if not fetch_error_occurred and not self.cancellation_event.is_set(): self.log_signal.emit(f"â Post fetching complete. Total posts to process: {self.total_posts_to_process}") - - # --- De-duplicate posts by ID --- unique_posts_dict = {} for post in all_posts_data: post_id = post.get('id') if post_id is not None: - # Keep the first occurrence of each post ID if post_id not in unique_posts_dict: unique_posts_dict[post_id] = post else: self.log_signal.emit(f"â ī¸ Skipping post with no ID: {post.get('title', 'Untitled')}") all_posts_data = list(unique_posts_dict.values()) - # --- End De-duplication --- self.total_posts_to_process = len(all_posts_data) self.log_signal.emit(f" Processed {len(unique_posts_dict)} unique posts after de-duplication.") @@ -3121,19 +3016,17 @@ class DownloaderApp(QWidget): 'num_file_threads', 'skip_current_file_flag', 'manga_date_file_counter_ref', 'manga_mode_active', 'manga_filename_style' ] - # Ensure 'allow_multipart_download' is also considered for optional keys if it has a default in PostProcessorWorker ppw_optional_keys_with_defaults = { 'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list', 'show_external_links', 'extract_links_only', 'duplicate_file_mode', # Added duplicate_file_mode here 'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style', 'manga_date_file_counter_ref', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file } - - # --- Batching Logic --- if num_post_workers > POST_WORKER_BATCH_THRESHOLD and self.total_posts_to_process > POST_WORKER_NUM_BATCHES : self.log_signal.emit(f" High thread count ({num_post_workers}) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES} parts.") import math # Moved import here + tasks_submitted_in_batch_segment = 0 batch_size = math.ceil(self.total_posts_to_process / POST_WORKER_NUM_BATCHES) submitted_count_in_batching = 0 @@ -3163,6 +3056,10 @@ class DownloaderApp(QWidget): success = self._submit_post_to_worker_pool(post_data_item, worker_args_template, num_file_dl_threads_for_each_worker, emitter_for_worker, ppw_expected_keys, ppw_optional_keys_with_defaults) if success: submitted_count_in_batching += 1 + tasks_submitted_in_batch_segment += 1 # Increment counter + if tasks_submitted_in_batch_segment % 10 == 0: # Yield roughly every 10 tasks + time.sleep(0.005) # Slightly longer sleep to yield GIL + tasks_submitted_in_batch_segment = 0 elif self.cancellation_event.is_set(): break @@ -3181,11 +3078,16 @@ class DownloaderApp(QWidget): else: # Standard submission (no batching) self.log_signal.emit(f" Submitting all {self.total_posts_to_process} tasks to pool directly...") submitted_count_direct = 0 + tasks_submitted_since_last_yield = 0 for post_data_item in all_posts_data: if self.cancellation_event.is_set(): break success = self._submit_post_to_worker_pool(post_data_item, worker_args_template, num_file_dl_threads_for_each_worker, emitter_for_worker, ppw_expected_keys, ppw_optional_keys_with_defaults) if success: submitted_count_direct += 1 + tasks_submitted_since_last_yield += 1 # Increment counter + if tasks_submitted_since_last_yield % 10 == 0: # Yield roughly every 10 tasks + time.sleep(0.005) # Slightly longer sleep to yield GIL + tasks_submitted_since_last_yield = 0 elif self.cancellation_event.is_set(): break @@ -3222,7 +3124,6 @@ class DownloaderApp(QWidget): self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) except Exception as e: self.log_signal.emit(f"â Error in _handle_future_result callback: {e}\n{traceback.format_exc(limit=2)}") - # If an error occurs, ensure we don't get stuck waiting for this future if self.processed_posts_count < self.total_posts_to_process: self.processed_posts_count = self.total_posts_to_process # Mark as if all processed to allow finish @@ -3253,7 +3154,6 @@ class DownloaderApp(QWidget): ] def set_ui_enabled(self, enabled): - # This list contains all widgets whose enabled state might change. all_potentially_toggleable_widgets = [ self.link_input, self.dir_input, self.dir_button, self.page_range_label, self.start_page_input, self.to_label, self.end_page_input, @@ -3291,8 +3191,6 @@ class DownloaderApp(QWidget): self.external_links_checkbox.setEnabled(can_enable_ext_links) if self.is_paused and not is_only_links and not is_only_archives: self.external_links_checkbox.setEnabled(True) - - # Handle "Use Cookie" checkbox and text input if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setEnabled(enabled or self.is_paused) self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked()) # This will handle cookie_text_input's enabled state @@ -3302,19 +3200,14 @@ class DownloaderApp(QWidget): if self.log_verbosity_toggle_button: self.log_verbosity_toggle_button.setEnabled(True) # New button, always enabled multithreading_currently_on = self.use_multithreading_checkbox.isChecked() - # Thread count related widgets follow 'enabled' strictly (disabled if paused) if self.thread_count_input: self.thread_count_input.setEnabled(enabled and multithreading_currently_on) if self.thread_count_label: self.thread_count_label.setEnabled(enabled and multithreading_currently_on) subfolders_currently_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolder_per_post_checkbox: self.use_subfolder_per_post_checkbox.setEnabled(enabled or (self.is_paused and self.use_subfolder_per_post_checkbox in widgets_to_enable_on_pause)) - - # --- Main Action Buttons --- self.download_btn.setEnabled(enabled) # Start Download only enabled when fully idle self.cancel_btn.setEnabled(download_is_active_or_paused) # Cancel enabled if running or paused - - # Pause button logic if self.pause_btn: self.pause_btn.setEnabled(download_is_active_or_paused) if download_is_active_or_paused: @@ -3327,15 +3220,11 @@ class DownloaderApp(QWidget): if enabled: # Ensure these are updated based on current (possibly reset) checkbox states if self.pause_event: self.pause_event.clear() - - # --- UI Updates based on current states --- - # These should run if UI is idle OR if paused (to reflect changes made during pause) if enabled or self.is_paused: self._handle_multithreading_toggle(multithreading_currently_on) self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False) self.update_custom_folder_visibility(self.link_input.text()) self.update_page_range_enabled_state() - # Re-evaluate filter mode as radio buttons might have been changed during pause if self.radio_group and self.radio_group.checkedButton(): self._handle_filter_mode_change(self.radio_group.checkedButton(), True) self.update_ui_for_subfolders(subfolders_currently_on) # Re-evaluate subfolder UI @@ -3354,8 +3243,6 @@ class DownloaderApp(QWidget): def _perform_soft_ui_reset(self, preserve_url=None, preserve_dir=None): """Resets UI elements and some state to app defaults, then applies preserved inputs.""" self.log_signal.emit("đ Performing soft UI reset...") - - # 1. Reset UI fields to their visual defaults self.link_input.clear() # Will be set later if preserve_url is given self.dir_input.clear() # Will be set later if preserve_dir is given self.custom_folder_input.clear(); self.character_input.clear(); @@ -3368,14 +3255,9 @@ class DownloaderApp(QWidget): self.external_links_checkbox.setChecked(False) if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False) if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setChecked(self.use_cookie_setting) # Reset to loaded or False - - # For soft reset, if a cookie file was selected, keep it displayed if "Use Cookie" remains checked. - # Otherwise, clear it. The _update_cookie_input_visibility will handle the display. if not (hasattr(self, 'use_cookie_checkbox') and self.use_cookie_checkbox.isChecked()): self.selected_cookie_filepath = None if hasattr(self, 'cookie_text_input'): self.cookie_text_input.setText(self.cookie_text_setting if self.use_cookie_setting else "") # Reset to loaded or empty - - # 2. Reset internal state for UI-managed settings to app defaults (not from QSettings) self.allow_multipart_download_setting = False # Default to OFF self._update_multipart_toggle_button_text() @@ -3387,14 +3269,10 @@ class DownloaderApp(QWidget): self.manga_filename_style = STYLE_POST_TITLE # Reset to app default self._update_manga_filename_style_button_text() - - # 3. Restore preserved URL and Directory if preserve_url is not None: self.link_input.setText(preserve_url) if preserve_dir is not None: self.dir_input.setText(preserve_dir) - - # 4. Reset operational state variables (but not session-based downloaded_files/hashes) self.external_link_queue.clear(); self.extracted_links_cache = [] self._is_processing_external_link_queue = False; self._current_link_post_title = None if self.pause_event: self.pause_event.clear() @@ -3402,19 +3280,14 @@ class DownloaderApp(QWidget): self.download_counter = 0; self.skip_counter = 0 self.all_kept_original_filenames = [] self.is_paused = False # Reset pause state on soft reset - - # 5. Update UI based on new (default or preserved) states self._handle_filter_mode_change(self.radio_group.checkedButton(), True) self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) self.filter_character_list(self.character_search_input.text()) self.set_ui_enabled(True) # This enables buttons and calls other UI update methods - - # Explicitly call these to ensure they reflect changes from preserved inputs self.update_custom_folder_visibility(self.link_input.text()) self.update_page_range_enabled_state() self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) - # update_ui_for_manga_mode is called within set_ui_enabled self.log_signal.emit("â Soft UI reset complete. Preserved URL and Directory (if provided).") @@ -3443,8 +3316,6 @@ class DownloaderApp(QWidget): if self.pause_event: self.pause_event.clear() self.log_signal.emit("âšī¸ UI reset. Ready for new operation. Background tasks are being terminated.") self.is_paused = False # Ensure pause state is reset - - # Also clear retryable files on a manual cancel, as the context is lost. if self.retryable_failed_files_info: self.log_signal.emit(f" Discarding {len(self.retryable_failed_files_info)} pending retryable file(s) due to cancellation.") self.retryable_failed_files_info.clear() @@ -3456,8 +3327,6 @@ class DownloaderApp(QWidget): kept_original_names_list = [] status_message = "Cancelled by user" if cancelled_by_user else "Finished" - - # If cancelled, don't offer retry for this session's failures if cancelled_by_user and self.retryable_failed_files_info: self.log_signal.emit(f" Download cancelled, discarding {len(self.retryable_failed_files_info)} file(s) that were pending retry.") self.retryable_failed_files_info.clear() @@ -3514,8 +3383,6 @@ class DownloaderApp(QWidget): if self.pause_event: self.pause_event.clear() self.cancel_btn.setEnabled(False) self.is_paused = False # Reset pause state when download finishes - - # Offer to retry failed files if any were collected and not cancelled if not cancelled_by_user and self.retryable_failed_files_info: num_failed = len(self.retryable_failed_files_info) reply = QMessageBox.question(self, "Retry Failed Downloads?", @@ -3556,8 +3423,6 @@ class DownloaderApp(QWidget): num_retry_threads = 1 # Default to 1 if input is bad self.retry_thread_pool = ThreadPoolExecutor(max_workers=num_retry_threads, thread_name_prefix='RetryFile_') - - # Prepare common arguments for PostProcessorWorker instances during retry common_ppw_args_for_retry = { 'download_root': self.dir_input.text().strip(), 'known_names': list(KNOWN_NAMES), @@ -3581,7 +3446,6 @@ class DownloaderApp(QWidget): 'char_filter_scope': self.get_char_filter_scope(), 'remove_from_filename_words_list': [word.strip() for word in self.remove_from_filename_input.text().strip().split(',') if word.strip()] if hasattr(self, 'remove_from_filename_input') else [], 'allow_multipart_download': self.allow_multipart_download_setting, - # These are not strictly needed for retry of a single file if path is fixed, but good to pass 'filter_character_list': None, 'dynamic_character_filter_holder': None, 'target_post_id_from_initial_url': None, # Not relevant for file retry @@ -3597,12 +3461,7 @@ class DownloaderApp(QWidget): def _execute_single_file_retry(self, job_details, common_args): """Executes a single file download retry attempt.""" - # Construct a dummy post_data, service, user_id, api_url_input for PPW init dummy_post_data = {'id': job_details['original_post_id_for_log'], 'title': job_details['post_title']} - # Extract service/user from a known URL or pass them if available in job_details - # For simplicity, assuming we might not have original service/user easily. - # This might affect some logging or minor details in PPW if it relies on them beyond post_id. - # Let's assume job_details can store 'service' and 'user_id' from the original post. ppw_init_args = { **common_args, @@ -3678,7 +3537,6 @@ class DownloaderApp(QWidget): self.log_verbosity_toggle_button.setText(self.CLOSED_EYE_ICON) # Monkey icon self.log_verbosity_toggle_button.setToolTip("Current View: Missed Character Log. Click to switch to Progress Log.") if self.progress_log_label: self.progress_log_label.setText("đĢ Missed Character Log:") - # self.log_signal.emit("="*20 + " Switched to Missed Character Log View " + "="*20) # Optional log message else: # current_log_view == 'missed_character' self.current_log_view = 'progress' if self.log_view_stack: self.log_view_stack.setCurrentIndex(0) # Show progress log @@ -3686,7 +3544,6 @@ class DownloaderApp(QWidget): self.log_verbosity_toggle_button.setText(self.EYE_ICON) # Open eye icon self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") if self.progress_log_label: self.progress_log_label.setText("đ Progress Log:") - # self.log_signal.emit("="*20 + " Switched to Progress Log View " + "="*20) # Optional log message def reset_application_state(self): if self._is_download_active(): QMessageBox.warning(self, "Reset Error", "Cannot reset while a download is in progress. Please cancel first."); return @@ -3705,7 +3562,6 @@ class DownloaderApp(QWidget): self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None self.progress_label.setText("Progress: Idle"); self.file_progress_label.setText("") with self.downloaded_files_lock: count = len(self.downloaded_files); self.downloaded_files.clear(); - # Reset old summarization state (if any remnants) and new bold list state self.missed_title_key_terms_count.clear() self.missed_title_key_terms_examples.clear() self.logged_summary_for_key_term.clear() @@ -3747,13 +3603,9 @@ class DownloaderApp(QWidget): self.external_links_checkbox.setChecked(False) if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False) if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setChecked(False) # Default to False on full reset - - # On full reset, always clear the selected cookie file path self.selected_cookie_filepath = None if hasattr(self, 'cookie_text_input'): self.cookie_text_input.clear() # Clear cookie text on full reset - - # Reset old summarization state (if any remnants) and new bold list state self.missed_title_key_terms_count.clear() self.missed_title_key_terms_examples.clear() self.logged_summary_for_key_term.clear() @@ -3782,7 +3634,6 @@ class DownloaderApp(QWidget): self.download_btn.setEnabled(True); self.cancel_btn.setEnabled(False) if self.reset_button: self.reset_button.setEnabled(True) - # self.basic_log_mode is False after reset, so Full Log is active if self.log_verbosity_toggle_button: # Reset eye button to show Progress Log self.log_verbosity_toggle_button.setText(self.EYE_ICON) self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") @@ -3794,9 +3645,14 @@ class DownloaderApp(QWidget): reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The name '{character_name}' was encountered or used as a filter.\nIt's not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) result = (reply == QMessageBox.Yes) if result: - self.new_char_input.setText(character_name) - if self.add_new_character(): self.log_signal.emit(f"â Added '{character_name}' to known names via background prompt.") - else: result = False; self.log_signal.emit(f"âšī¸ Adding '{character_name}' via background prompt was declined or failed.") + # For background prompts, assume it's a simple, non-group entry. + # The character_name here is the primary name of a filter object. + if self.add_new_character(name_to_add=character_name, + is_group_to_add=False, # Background prompts add simple entries + aliases_to_add=[character_name], + suppress_similarity_prompt=False): # Allow similarity prompt for background adds + self.log_signal.emit(f"â Added '{character_name}' to known names via background prompt.") + else: result = False; self.log_signal.emit(f"âšī¸ Adding '{character_name}' via background prompt was declined, failed, or a similar name conflict was not overridden.") self.character_prompt_response_signal.emit(result) def receive_add_character_result(self, result): @@ -3829,7 +3685,6 @@ class DownloaderApp(QWidget): ) def _toggle_multipart_mode(self): - # If currently OFF, and user is trying to turn it ON if not self.allow_multipart_download_setting: msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Warning) @@ -3852,7 +3707,6 @@ class DownloaderApp(QWidget): msg_box.exec_() if msg_box.clickedButton() == cancel_button: - # User cancelled, so don't change the setting (it's already False) self.log_signal.emit("âšī¸ Multi-part download enabling cancelled by user.") return # Exit without changing the state or button text @@ -3895,13 +3749,10 @@ if __name__ == '__main__': else: print(f"Warning: Application icon 'Kemono.ico' not found at {icon_path}") downloader_app_instance = DownloaderApp() - - # --- Calculate initial window size based on screen dimensions --- primary_screen = QApplication.primaryScreen() if not primary_screen: screens = QApplication.screens() if not screens: - # Absolute fallback if no screen information is available downloader_app_instance.resize(1024, 768) downloader_app_instance.show() sys.exit(qt_app.exec_()) @@ -3910,8 +3761,6 @@ if __name__ == '__main__': available_geo = primary_screen.availableGeometry() screen_width = available_geo.width() screen_height = available_geo.height() - - # Define desired size relative to screen and minimums min_app_width = 960 # Minimum width for the app to be usable min_app_height = 680 # Minimum height desired_app_width_ratio = 0.80 # Use 80% of available screen width @@ -3919,16 +3768,12 @@ if __name__ == '__main__': app_width = max(min_app_width, int(screen_width * desired_app_width_ratio)) app_height = max(min_app_height, int(screen_height * desired_app_height_ratio)) - - # Ensure the calculated size doesn't exceed the available screen space app_width = min(app_width, screen_width) app_height = min(app_height, screen_height) downloader_app_instance.resize(app_width, app_height) downloader_app_instance.show() downloader_app_instance._center_on_screen() - - # TourDialog is now defined in this file, so we can call it directly. try: tour_result = TourDialog.run_tour_if_needed(downloader_app_instance) if tour_result == QDialog.Accepted: print("Tour completed by user.") diff --git a/multipart_downloader.py b/multipart_downloader.py index d78ad83..e7a6be6 100644 --- a/multipart_downloader.py +++ b/multipart_downloader.py @@ -33,21 +33,13 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.") chunk_headers = headers.copy() - # end_byte can be -1 for 0-byte files, meaning download from start_byte to end of file (which is start_byte itself) if end_byte != -1 : # For 0-byte files, end_byte might be -1, Range header should not be set or be 0-0 chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}" elif start_byte == 0 and end_byte == -1: # Specifically for 0-byte files - # Some servers might not like Range: bytes=0--1. - # For a 0-byte file, we might not even need a range header, or Range: bytes=0-0 - # Let's try without for 0-byte, or rely on server to handle 0-0 if Content-Length was 0. - # If Content-Length was 0, the main function might handle it directly. - # This chunking logic is primarily for files > 0 bytes. - # For now, if end_byte is -1, it implies a 0-byte file, so we expect 0 bytes. pass bytes_this_chunk = 0 - # last_progress_emit_time_for_chunk = time.time() # Replaced by global_emit_time_ref logic last_speed_calc_time = time.time() bytes_at_last_speed_calc = 0 @@ -71,18 +63,12 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, if attempt > 0: logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying download (Attempt {attempt}/{MAX_CHUNK_DOWNLOAD_RETRIES})...") time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1))) - # Reset speed calculation on retry last_speed_calc_time = time.time() bytes_at_last_speed_calc = bytes_this_chunk # Current progress of this chunk - - # Enhanced log message for chunk start log_msg = f" đ [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}" logger_func(log_msg) - # print(f"DEBUG_MULTIPART: {log_msg}") # Direct console print for debugging response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk) response.raise_for_status() - - # For 0-byte files, if end_byte was -1, we expect 0 content. if start_byte == 0 and end_byte == -1 and int(response.headers.get('Content-Length', 0)) == 0: logger_func(f" [Chunk {part_num + 1}/{total_parts}] Confirmed 0-byte file.") with progress_data['lock']: @@ -112,7 +98,6 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, bytes_this_chunk += len(data_segment) with progress_data['lock']: - # Increment both the chunk's downloaded and the overall downloaded progress_data['total_downloaded_so_far'] += len(data_segment) progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk progress_data['chunks_status'][part_num]['active'] = True @@ -125,17 +110,12 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps last_speed_calc_time = current_time bytes_at_last_speed_calc = bytes_this_chunk - - # Throttle emissions globally for this file download if emitter and (current_time - global_emit_time_ref[0] > 0.25): # Max ~4Hz for the whole file global_emit_time_ref[0] = current_time # Update shared last emit time - - # Prepare and emit the status_list_copy status_list_copy = [dict(s) for s in progress_data['chunks_status']] # Make a deep enough copy if isinstance(emitter, queue.Queue): emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)}) elif hasattr(emitter, 'file_progress_signal'): # PostProcessorSignals-like - # Ensure we read the latest total downloaded from progress_data emitter.file_progress_signal.emit(api_original_filename, status_list_copy) return bytes_this_chunk, True @@ -150,8 +130,6 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, except Exception as e: logger_func(f" â [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}") return bytes_this_chunk, False - - # Ensure final status is marked as inactive if loop finishes due to retries with progress_data['lock']: progress_data['chunks_status'][part_num]['active'] = False progress_data['chunks_status'][part_num]['speed_bps'] = 0 @@ -236,11 +214,8 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, if cancellation_event and cancellation_event.is_set(): logger_func(f" Multi-part download for '{api_original_filename}' cancelled by main event.") all_chunks_successful = False - - # Ensure a final progress update is sent with all chunks marked inactive (unless still active due to error) if emitter_for_multipart: with progress_data['lock']: - # Ensure all chunks are marked inactive for the final signal if download didn't fully succeed or was cancelled status_list_copy = [dict(s) for s in progress_data['chunks_status']] if isinstance(emitter_for_multipart, queue.Queue): emitter_for_multipart.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)}) @@ -254,12 +229,10 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, for buf in iter(lambda: f_hash.read(4096*10), b''): # Read in larger buffers for hashing md5_hasher.update(buf) calculated_hash = md5_hasher.hexdigest() - # Return an open file handle for the caller to manage (e.g., for compression) - # The caller is responsible for closing this handle and renaming/deleting the .part file. return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb') else: logger_func(f" â Multi-part download failed for '{api_original_filename}'. Success: {all_chunks_successful}, Bytes: {total_bytes_from_chunks}/{total_size}. Cleaning up.") if os.path.exists(temp_file_path): try: os.remove(temp_file_path) except OSError as e: logger_func(f" Failed to remove temp part file '{temp_file_path}': {e}") - return False, total_bytes_from_chunks, None, None \ No newline at end of file + return False, total_bytes_from_chunks, None, None diff --git a/readme.md b/readme.md index 9110a66..12e4fba 100644 --- a/readme.md +++ b/readme.md @@ -11,13 +11,13 @@ Built with **PyQt5**, this tool is ideal for users who want deep filtering, cust --- -## ⨠What's New in v3.5.0? +## What's New in v3.5.0? Version 3.5.0 focuses on enhancing access to content and providing even smarter organization: -### đĒ Enhanced Cookie Management +### Cookie Management -- **Access Restricted Content:** Seamlessly download from Kemono/Coomer as if you were logged in by using your browser's cookies. +- **Access Content:** Seamlessly download from Kemono/Coomer as if you were logged in by using your browser's cookies. - **Flexible Input:** - Directly paste your cookie string (e.g., `name1=value1; name2=value2`). - Browse and load cookies from a `cookies.txt` file (Netscape format). @@ -27,7 +27,7 @@ Version 3.5.0 focuses on enhancing access to content and providing even smarter --- -### đī¸ Advanced `Known.txt` for Smart Folder Organization +### Advanced `Known.txt` for Smart Folder Organization - **Fine-Grained Control:** Take your automatic folder organization to the next level with a personalized list of names, series titles, and keywords in `Known.txt`. - **Primary Names & Aliases:** Define a main folder name and link multiple aliases to it. For example, `([Power], powwr, pwr, Blood devil)` ensures any post matching "Power" or "powwr" (in title or filename, depending on settings) gets saved into a "Power" folder. Simple entries like `My Series` are also supported. @@ -35,10 +35,10 @@ Version 3.5.0 focuses on enhancing access to content and providing even smarter - **User-Friendly Management:** Add or remove primary names directly through the UI, or click "Open Known.txt" for advanced editing (e.g., setting up aliases). --- -## ⨠What's in v3.4.0? (Previous Update) +## What's in v3.4.0? (Previous Update) This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience: -### đ Enhanced Manga/Comic Mode +### Enhanced Manga/Comic Mode - **New "Date Based" Filename Style:** @@ -52,7 +52,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### âī¸ "Remove Words from Filename" Feature +### "Remove Words from Filename" Feature - Specify comma-separated words or phrases (case-insensitive) that will be automatically removed from filenames. @@ -60,7 +60,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đĻ New "Only Archives" File Filter Mode +### New "Only Archives" File Filter Mode - Exclusively downloads `.zip` and `.rar` files. @@ -68,7 +68,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đŖī¸ Improved Character Filter Scope - "Comments (Beta)" +### Improved Character Filter Scope - "Comments (Beta)" - **File-First Check:** Prioritizes matching filenames before checking post comments for character names. @@ -76,7 +76,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đ§ Refined "Missed Character Log" +### Refined "Missed Character Log" - Displays a capitalized, alphabetized list of key terms from skipped post titles. @@ -84,25 +84,25 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đ Enhanced Multi-part Download Progress +### Enhanced Multi-part Download Progress - Granular visibility into active chunk downloads and combined speed for large files. --- -### đēī¸ Updated Onboarding Tour +### Updated Onboarding Tour - Improved guide for new users, covering v3.4.0 features and existing core functions. --- -### đĄī¸ Robust Configuration Path +### Robust Configuration Path -- Settings and `Known.txt` are now stored in the system-standard application data folder (e.g., `AppData`, `~/.local/share`). +- Settings and `Known.txt` are now stored in the same folder as app. --- -## đĨī¸ Core Features +## Core Features --- @@ -122,7 +122,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đ§ Smart Filtering +### Smart Filtering - **Character Name Filtering:** - Use `Tifa, Aerith` or group `(Boa, Hancock)` â folder `Boa Hancock` @@ -149,7 +149,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đ Manga/Comic Mode (Creator Feeds Only) +### Manga/Comic Mode (Creator Feeds Only) - **Chronological Processing** â Oldest posts first @@ -162,7 +162,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đ Folder Structure & Naming +### Folder Structure & Naming - **Subfolders:** - Auto-created based on character name, post title, or `Known.txt` @@ -173,7 +173,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đŧī¸ Thumbnail & Compression Tools +### Thumbnail & Compression Tools - **Download Thumbnails Only** @@ -182,7 +182,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### âī¸ Performance Features +### Performance Features - **Multithreading:** - For both post processing and file downloading @@ -194,7 +194,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đ Logging & Progress +### Logging & Progress - **Real-time Logs:** Activity, errors, skipped posts @@ -206,7 +206,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -### đī¸ Config System +### Config System - **`Known.txt` for Smart Folder Naming:** - A user-editable file (`Known.txt`) stores a list of preferred names, series titles, or keywords. @@ -221,7 +221,7 @@ This version brings significant enhancements to manga/comic downloading, filteri --- -## đģ Installation +## Installation --- @@ -241,7 +241,7 @@ pip install PyQt5 requests Pillow *** -## **đ ī¸ Build a Standalone Executable (Optional)** +## ** Build a Standalone Executable (Optional)** 1. Install PyInstaller: ```bash @@ -257,14 +257,14 @@ pyinstaller --name "Kemono Downloader" --onefile --windowed --icon="Kemono.ico" *** -## **đ Config Files** +## ** Config Files** - `Known.txt` â character/show names used for folder organization - Supports simple names (e.g., `My Series`) and grouped names with a primary folder name and aliases (e.g., `([Primary Folder Name], alias1, alias2)`). *** -## **đŦ Feedback & Support** +## ** Feedback & Support** Issues? Suggestions? Open an issue on the [GitHub repository](https://github.com/Yuvi9587/kemono-downloader) or join our community.