diff --git a/src/core/api_client.py b/src/core/api_client.py index 03dea4d..2d56524 100644 --- a/src/core/api_client.py +++ b/src/core/api_client.py @@ -115,217 +115,248 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, except ValueError as e: raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}") -def download_from_api ( -api_url_input , -logger =print , -start_page =None , -end_page =None , -manga_mode =False , -cancellation_event =None , -pause_event =None , -use_cookie =False , -cookie_text ="", -selected_cookie_file =None , -app_base_dir =None , -manga_filename_style_for_sort_check =None +def download_from_api( + api_url_input, + logger=print, + start_page=None, + end_page=None, + manga_mode=False, + cancellation_event=None, + pause_event=None, + use_cookie=False, + cookie_text="", + selected_cookie_file=None, + app_base_dir=None, + manga_filename_style_for_sort_check=None, + processed_post_ids=None # --- ADD THIS ARGUMENT --- ): - headers ={ - 'User-Agent':'Mozilla/5.0', - 'Accept':'application/json' + headers = { + 'User-Agent': 'Mozilla/5.0', + 'Accept': 'application/json' } - service ,user_id ,target_post_id =extract_post_info (api_url_input ) + # --- ADD THIS BLOCK --- + # Ensure processed_post_ids is a set for fast lookups + if processed_post_ids is None: + processed_post_ids = set() + else: + processed_post_ids = set(processed_post_ids) + # --- END OF ADDITION --- - if cancellation_event and cancellation_event .is_set (): - logger (" Download_from_api cancelled at start.") - return + service, user_id, target_post_id = extract_post_info(api_url_input) - parsed_input_url_for_domain =urlparse (api_url_input ) - api_domain =parsed_input_url_for_domain .netloc - if not any (d in api_domain .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']): - logger (f"⚠️ Unrecognized domain '{api_domain }' from input URL. Defaulting to kemono.su for API calls.") - api_domain ="kemono.su" - cookies_for_api =None - if use_cookie and app_base_dir : - cookies_for_api =prepare_cookies_for_request (use_cookie ,cookie_text ,selected_cookie_file ,app_base_dir ,logger ,target_domain =api_domain ) - if target_post_id : - direct_post_api_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }/post/{target_post_id }" - logger (f" Attempting direct fetch for target post: {direct_post_api_url }") - try : - direct_response =requests .get (direct_post_api_url ,headers =headers ,timeout =(10 ,30 ),cookies =cookies_for_api ) - direct_response .raise_for_status () - direct_post_data =direct_response .json () - if isinstance (direct_post_data ,list )and direct_post_data : - direct_post_data =direct_post_data [0 ] - if isinstance (direct_post_data ,dict )and 'post'in direct_post_data and isinstance (direct_post_data ['post'],dict ): - direct_post_data =direct_post_data ['post'] - if isinstance (direct_post_data ,dict )and direct_post_data .get ('id')==target_post_id : - logger (f" ✅ Direct fetch successful for post {target_post_id }.") - yield [direct_post_data ] - return - else : - response_type =type (direct_post_data ).__name__ - response_snippet =str (direct_post_data )[:200 ] - logger (f" ⚠️ Direct fetch for post {target_post_id } returned unexpected data (Type: {response_type }, Snippet: '{response_snippet }'). Falling back to pagination.") - except requests .exceptions .RequestException as e : - logger (f" ⚠️ Direct fetch failed for post {target_post_id }: {e }. Falling back to pagination.") - except Exception as e : - logger (f" ⚠️ Unexpected error during direct fetch for post {target_post_id }: {e }. Falling back to pagination.") - if not service or not user_id : - logger (f"❌ Invalid URL or could not extract service/user: {api_url_input }") - return - if target_post_id and (start_page or end_page ): - logger ("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).") + if cancellation_event and cancellation_event.is_set(): + logger(" Download_from_api cancelled at start.") + return - is_manga_mode_fetch_all_and_sort_oldest_first =manga_mode and (manga_filename_style_for_sort_check !=STYLE_DATE_POST_TITLE )and not target_post_id - api_base_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }" - page_size =50 - if is_manga_mode_fetch_all_and_sort_oldest_first : - logger (f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...") - all_posts_for_manga_mode =[] - current_offset_manga =0 - if start_page and start_page >1 : - current_offset_manga =(start_page -1 )*page_size - logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).") - elif start_page : - logger (f" Manga Mode: Starting fetch from page 1 (offset 0).") - if end_page : - logger (f" Manga Mode: Will fetch up to page {end_page }.") - while True : - if pause_event and pause_event .is_set (): - logger (" Manga mode post fetching paused...") - while pause_event .is_set (): - if cancellation_event and cancellation_event .is_set (): - logger (" Manga mode post fetching cancelled while paused.") - break - time .sleep (0.5 ) - if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.") - if cancellation_event and cancellation_event .is_set (): - logger (" Manga mode post fetching cancelled.") - break - current_page_num_manga =(current_offset_manga //page_size )+1 - if end_page and current_page_num_manga >end_page : - logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.") - break - try : - posts_batch_manga =fetch_posts_paginated (api_base_url ,headers ,current_offset_manga ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) - if not isinstance (posts_batch_manga ,list ): - logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.") - break - if not posts_batch_manga : - logger ("✅ Reached end of posts (Manga Mode fetch all).") - if start_page and not end_page and current_page_num_manga 1: + current_offset_manga = (start_page - 1) * page_size + logger(f" Manga Mode: Starting fetch from page {start_page} (offset {current_offset_manga}).") + elif start_page: + logger(f" Manga Mode: Starting fetch from page 1 (offset 0).") + if end_page: + logger(f" Manga Mode: Will fetch up to page {end_page}.") + while True: + if pause_event and pause_event.is_set(): + logger(" Manga mode post fetching paused...") + while pause_event.is_set(): + if cancellation_event and cancellation_event.is_set(): + logger(" Manga mode post fetching cancelled while paused.") + break + time.sleep(0.5) + if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.") + if cancellation_event and cancellation_event.is_set(): + logger(" Manga mode post fetching cancelled.") + break + current_page_num_manga = (current_offset_manga // page_size) + 1 + if end_page and current_page_num_manga > end_page: + logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.") + break + try: + posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) + if not isinstance(posts_batch_manga, list): + logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.") + break + if not posts_batch_manga: + logger("✅ Reached end of posts (Manga Mode fetch all).") + if start_page and not end_page and current_page_num_manga < start_page: + logger(f" Manga Mode: No posts found on or after specified start page {start_page}.") + elif end_page and current_page_num_manga <= end_page and not all_posts_for_manga_mode: + logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).") + break + all_posts_for_manga_mode.extend(posts_batch_manga) + current_offset_manga += page_size + time.sleep(0.6) + except RuntimeError as e: + if "cancelled by user" in str(e).lower(): + logger(f"ℹ️ Manga mode pagination stopped due to cancellation: {e}") + else: + logger(f"❌ {e}\n Aborting manga mode pagination.") + break + except Exception as e: + logger(f"❌ Unexpected error during manga mode fetch: {e}") + traceback.print_exc() + break + if cancellation_event and cancellation_event.is_set(): return + if all_posts_for_manga_mode: + # --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE --- + if processed_post_ids: + original_count = len(all_posts_for_manga_mode) + all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids] + skipped_count = original_count - len(all_posts_for_manga_mode) + if skipped_count > 0: + logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.") + # --- END OF ADDITION --- + logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...") + def sort_key_tuple(post): + published_date_str = post.get('published') + added_date_str = post.get('added') + post_id_str = post.get('id', "0") + primary_sort_val = "0000-00-00T00:00:00" + if published_date_str: + primary_sort_val = published_date_str + elif added_date_str: + logger(f" ⚠️ Post ID {post_id_str} missing 'published' date, using 'added' date '{added_date_str}' for primary sorting.") + primary_sort_val = added_date_str + else: + logger(f" ⚠️ Post ID {post_id_str} missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).") + secondary_sort_val = 0 + try: + secondary_sort_val = int(post_id_str) + except ValueError: + logger(f" ⚠️ Post ID '{post_id_str}' is not a valid integer for secondary sorting, using 0.") + return (primary_sort_val, secondary_sort_val) + all_posts_for_manga_mode.sort(key=sort_key_tuple) + for i in range(0, len(all_posts_for_manga_mode), page_size): + if cancellation_event and cancellation_event.is_set(): + logger(" Manga mode post yielding cancelled.") + break + yield all_posts_for_manga_mode[i:i + page_size] + return - if manga_mode and not target_post_id and (manga_filename_style_for_sort_check ==STYLE_DATE_POST_TITLE ): - logger (f" Manga Mode (Style: {STYLE_DATE_POST_TITLE }): Processing posts in default API order (newest first).") + if manga_mode and not target_post_id and (manga_filename_style_for_sort_check == STYLE_DATE_POST_TITLE): + logger(f" Manga Mode (Style: {STYLE_DATE_POST_TITLE}): Processing posts in default API order (newest first).") - current_page_num =1 - current_offset =0 - processed_target_post_flag =False - if start_page and start_page >1 and not target_post_id : - current_offset =(start_page -1 )*page_size - current_page_num =start_page - logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).") - while True : - if pause_event and pause_event .is_set (): - logger (" Post fetching loop paused...") - while pause_event .is_set (): - if cancellation_event and cancellation_event .is_set (): - logger (" Post fetching loop cancelled while paused.") - break - time .sleep (0.5 ) - if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.") - if cancellation_event and cancellation_event .is_set (): - logger (" Post fetching loop cancelled.") - break - if target_post_id and processed_target_post_flag : - break - if not target_post_id and end_page and current_page_num >end_page : - logger (f"✅ Reached specified end page ({end_page }) for creator feed. Stopping.") - break - try : - posts_batch =fetch_posts_paginated (api_base_url ,headers ,current_offset ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) - if not isinstance (posts_batch ,list ): - logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).") - break - except RuntimeError as e : - if "cancelled by user"in str (e ).lower (): - logger (f"ℹ️ Pagination stopped due to cancellation: {e }") - else : - logger (f"❌ {e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).") - break - except Exception as e : - logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }") - traceback .print_exc () - break - if not posts_batch : - if target_post_id and not processed_target_post_flag : - logger (f"❌ Target post {target_post_id } not found after checking all available pages (API returned no more posts at offset {current_offset }).") - elif not target_post_id : - if current_page_num ==(start_page or 1 ): - logger (f"😕 No posts found on the first page checked (page {current_page_num }, offset {current_offset }).") - else : - logger (f"✅ Reached end of posts (no more content from API at offset {current_offset }).") - break - if target_post_id and not processed_target_post_flag : - matching_post =next ((p for p in posts_batch if str (p .get ('id'))==str (target_post_id )),None ) - if matching_post : - logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).") - yield [matching_post ] - processed_target_post_flag =True - elif not target_post_id : - yield posts_batch - if processed_target_post_flag : - break - current_offset +=page_size - current_page_num +=1 - time .sleep (0.6 ) - if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event .is_set ()): - logger (f"❌ Target post {target_post_id } could not be found after checking all relevant pages (final check after loop).") \ No newline at end of file + current_page_num = 1 + current_offset = 0 + processed_target_post_flag = False + if start_page and start_page > 1 and not target_post_id: + current_offset = (start_page - 1) * page_size + current_page_num = start_page + logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).") + while True: + if pause_event and pause_event.is_set(): + logger(" Post fetching loop paused...") + while pause_event.is_set(): + if cancellation_event and cancellation_event.is_set(): + logger(" Post fetching loop cancelled while paused.") + break + time.sleep(0.5) + if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.") + if cancellation_event and cancellation_event.is_set(): + logger(" Post fetching loop cancelled.") + break + if target_post_id and processed_target_post_flag: + break + if not target_post_id and end_page and current_page_num > end_page: + logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.") + break + try: + posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) + if not isinstance(posts_batch, list): + logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).") + break + except RuntimeError as e: + if "cancelled by user" in str(e).lower(): + logger(f"ℹ️ Pagination stopped due to cancellation: {e}") + else: + logger(f"❌ {e}\n Aborting pagination at page {current_page_num} (offset {current_offset}).") + break + except Exception as e: + logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}") + traceback.print_exc() + break + + # --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE --- + if processed_post_ids: + original_count = len(posts_batch) + posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids] + skipped_count = original_count - len(posts_batch) + if skipped_count > 0: + logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.") + # --- END OF ADDITION --- + + if not posts_batch: + if target_post_id and not processed_target_post_flag: + logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).") + elif not target_post_id: + if current_page_num == (start_page or 1): + logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).") + else: + logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).") + break + if target_post_id and not processed_target_post_flag: + matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None) + if matching_post: + logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).") + yield [matching_post] + processed_target_post_flag = True + elif not target_post_id: + yield posts_batch + if processed_target_post_flag: + break + current_offset += page_size + current_page_num += 1 + time.sleep(0.6) + if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()): + logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).") diff --git a/src/core/workers.py b/src/core/workers.py index 89bfb31..9d2a0f3 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -106,6 +106,7 @@ class PostProcessorWorker: text_export_format='txt', single_pdf_mode=False, project_root_dir=None, + processed_post_ids=None ): self .post =post_data self .download_root =download_root @@ -161,8 +162,10 @@ class PostProcessorWorker: self.session_lock = session_lock self.text_only_scope = text_only_scope self.text_export_format = text_export_format - self.single_pdf_mode = single_pdf_mode # <-- ADD THIS LINE + self.single_pdf_mode = single_pdf_mode self.project_root_dir = project_root_dir + self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] + if self .compress_images and Image is None : self .logger ("⚠️ Image compression disabled: Pillow library not found.") @@ -190,405 +193,340 @@ class PostProcessorWorker: time .sleep (0.5 ) if not self .check_cancel ():self .logger (f" {context_message } resumed.") return False - def _download_single_file (self ,file_info ,target_folder_path ,headers ,original_post_id_for_log ,skip_event , - post_title ="",file_index_in_post =0 ,num_files_in_this_post =1 , - manga_date_file_counter_ref =None ): - was_original_name_kept_flag =False - final_filename_saved_for_return ="" def _get_current_character_filters (self ): if self .dynamic_filter_holder : return self .dynamic_filter_holder .get_filters () return self .filter_character_list_objects_initial + + def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event, + post_title="", file_index_in_post=0, num_files_in_this_post=1, + manga_date_file_counter_ref=None, + forced_filename_override=None, + manga_global_file_counter_ref=None, folder_context_name_for_history=None): + was_original_name_kept_flag = False + final_filename_saved_for_return = "" + retry_later_details = None - def _download_single_file (self ,file_info ,target_folder_path ,headers ,original_post_id_for_log ,skip_event , - post_title ="",file_index_in_post =0 ,num_files_in_this_post =1 , - manga_date_file_counter_ref =None , - forced_filename_override =None , - manga_global_file_counter_ref =None ,folder_context_name_for_history =None ): - was_original_name_kept_flag =False - final_filename_saved_for_return ="" - retry_later_details =None + if self._check_pause(f"File download prep for '{file_info.get('name', 'unknown file')}'"): + return 0, 1, "", False, FILE_DOWNLOAD_STATUS_SKIPPED, None + if self.check_cancel() or (skip_event and skip_event.is_set()): + return 0, 1, "", False, FILE_DOWNLOAD_STATUS_SKIPPED, None + file_url = file_info.get('url') + cookies_to_use_for_file = None + if self.use_cookie: + 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')) + 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}'") + 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() + for skip_word in self.skip_words_list: + if skip_word.lower() in filename_to_check_for_skip_words: + self.logger(f" -> Skip File (Keyword in Original Name '{skip_word}'): '{api_original_filename}'. Scope: {self.skip_words_scope}") + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None - 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 + cleaned_original_api_filename = clean_filename(api_original_filename) + original_filename_cleaned_base, original_ext = os.path.splitext(cleaned_original_api_filename) + if not original_ext.startswith('.'): original_ext = '.' + original_ext if original_ext else '' - - - file_url =file_info .get ('url') - cookies_to_use_for_file =None - if self .use_cookie : - - 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')) - - - 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 }'") - 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 () - for skip_word in self .skip_words_list : - if skip_word .lower ()in filename_to_check_for_skip_words : - self .logger (f" -> Skip File (Keyword in Original Name '{skip_word }'): '{api_original_filename }'. Scope: {self .skip_words_scope }") - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - - cleaned_original_api_filename =clean_filename (api_original_filename ) - - original_filename_cleaned_base ,original_ext =os .path .splitext (cleaned_original_api_filename ) - - if not original_ext .startswith ('.'):original_ext ='.'+original_ext if original_ext else '' - if self .manga_mode_active : - - if self .manga_filename_style ==STYLE_ORIGINAL_NAME : - filename_to_save_in_main_path =cleaned_original_api_filename - if self .manga_date_prefix and self .manga_date_prefix .strip (): - cleaned_prefix =clean_filename (self .manga_date_prefix .strip ()) - if cleaned_prefix : - filename_to_save_in_main_path =f"{cleaned_prefix } {filename_to_save_in_main_path }" - else : - self .logger (f"⚠️ Manga Original Name Mode: Provided prefix '{self .manga_date_prefix }' was empty after cleaning. Using original name only.") - was_original_name_kept_flag =True - elif self .manga_filename_style ==STYLE_POST_TITLE : - if post_title and post_title .strip (): - cleaned_post_title_base =clean_filename (post_title .strip ()) - if num_files_in_this_post >1 : - if file_index_in_post ==0 : - filename_to_save_in_main_path =f"{cleaned_post_title_base }{original_ext }" - else : - filename_to_save_in_main_path =f"{cleaned_post_title_base }_{file_index_in_post }{original_ext }" - was_original_name_kept_flag =False - else : - filename_to_save_in_main_path =f"{cleaned_post_title_base }{original_ext }" - else : - filename_to_save_in_main_path =cleaned_original_api_filename - 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 - 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 ] - - with counter_lock : - counter_val_for_filename =manga_date_file_counter_ref [0 ] - manga_date_file_counter_ref [0 ]+=1 - - base_numbered_name =f"{counter_val_for_filename :03d}" - if self .manga_date_prefix and self .manga_date_prefix .strip (): - cleaned_prefix =clean_filename (self .manga_date_prefix .strip ()) - if cleaned_prefix : - filename_to_save_in_main_path =f"{cleaned_prefix } {base_numbered_name }{original_ext }" - else : - filename_to_save_in_main_path =f"{base_numbered_name }{original_ext }";self .logger (f"⚠️ Manga Date Mode: Provided prefix '{self .manga_date_prefix }' was empty after cleaning. Using number only.") - else : - filename_to_save_in_main_path =f"{base_numbered_name }{original_ext }" - 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 =cleaned_original_api_filename - elif self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : - if manga_global_file_counter_ref is not None and len (manga_global_file_counter_ref )==2 : - counter_val_for_filename =-1 - counter_lock =manga_global_file_counter_ref [1 ] - - with counter_lock : - counter_val_for_filename =manga_global_file_counter_ref [0 ] - manga_global_file_counter_ref [0 ]+=1 - - cleaned_post_title_base_for_global =clean_filename (post_title .strip ()if post_title and post_title .strip ()else "post") - filename_to_save_in_main_path =f"{cleaned_post_title_base_for_global }_{counter_val_for_filename :03d}{original_ext }" - else : - self .logger (f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename }'. Using original. Ref: {manga_global_file_counter_ref }") - filename_to_save_in_main_path =cleaned_original_api_filename - self .logger (f"⚠️ Manga mode (Title+GlobalNum Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path }' for post {original_post_id_for_log }.") + if self.manga_mode_active: + if self.manga_filename_style == STYLE_ORIGINAL_NAME: + filename_to_save_in_main_path = cleaned_original_api_filename + if self.manga_date_prefix and self.manga_date_prefix.strip(): + cleaned_prefix = clean_filename(self.manga_date_prefix.strip()) + if cleaned_prefix: + filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}" + else: + self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.") + was_original_name_kept_flag = True + elif self.manga_filename_style == STYLE_POST_TITLE: + if post_title and post_title.strip(): + cleaned_post_title_base = clean_filename(post_title.strip()) + if num_files_in_this_post > 1: + if file_index_in_post == 0: + filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}" + else: + filename_to_save_in_main_path = f"{cleaned_post_title_base}_{file_index_in_post}{original_ext}" + was_original_name_kept_flag = False + else: + filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}" + else: + filename_to_save_in_main_path = cleaned_original_api_filename + 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: + 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] + with counter_lock: + counter_val_for_filename = manga_date_file_counter_ref[0] + manga_date_file_counter_ref[0] += 1 + base_numbered_name = f"{counter_val_for_filename:03d}" + if self.manga_date_prefix and self.manga_date_prefix.strip(): + cleaned_prefix = clean_filename(self.manga_date_prefix.strip()) + if cleaned_prefix: + filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}" + else: + filename_to_save_in_main_path = f"{base_numbered_name}{original_ext}"; self.logger(f"⚠️ Manga Date Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using number only.") + else: + filename_to_save_in_main_path = f"{base_numbered_name}{original_ext}" + 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 = cleaned_original_api_filename + elif self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING: + if manga_global_file_counter_ref is not None and len(manga_global_file_counter_ref) == 2: + counter_val_for_filename = -1 + counter_lock = manga_global_file_counter_ref[1] + with counter_lock: + counter_val_for_filename = manga_global_file_counter_ref[0] + manga_global_file_counter_ref[0] += 1 + cleaned_post_title_base_for_global = clean_filename(post_title.strip() if post_title and post_title.strip() else "post") + filename_to_save_in_main_path = f"{cleaned_post_title_base_for_global}_{counter_val_for_filename:03d}{original_ext}" + else: + self.logger(f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_global_file_counter_ref}") + filename_to_save_in_main_path = cleaned_original_api_filename + self.logger(f"⚠️ Manga mode (Title+GlobalNum Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path}' for post {original_post_id_for_log}.") elif self.manga_filename_style == STYLE_POST_ID: if original_post_id_for_log and original_post_id_for_log != 'unknown_id': base_name = str(original_post_id_for_log) - # Always append the file index for consistency (e.g., xxxxxx_0, xxxxxx_1) filename_to_save_in_main_path = f"{base_name}_{file_index_in_post}{original_ext}" else: - # Fallback if post_id is somehow not available self.logger(f"⚠️ Manga mode (Post ID Style): Post ID missing. Using cleaned original filename '{cleaned_original_api_filename}'.") filename_to_save_in_main_path = cleaned_original_api_filename - elif self .manga_filename_style ==STYLE_DATE_POST_TITLE : - published_date_str =self .post .get ('published') - added_date_str =self .post .get ('added') - formatted_date_str ="nodate" + elif self.manga_filename_style == STYLE_DATE_POST_TITLE: + published_date_str = self.post.get('published') + added_date_str = self.post.get('added') + formatted_date_str = "nodate" + if published_date_str: + try: + formatted_date_str = published_date_str.split('T')[0] + except Exception: + self.logger(f" ⚠️ Could not parse 'published' date '{published_date_str}' for STYLE_DATE_POST_TITLE. Using 'nodate'.") + elif added_date_str: + try: + formatted_date_str = added_date_str.split('T')[0] + self.logger(f" ⚠️ Post ID {original_post_id_for_log} missing 'published' date, using 'added' date '{added_date_str}' for STYLE_DATE_POST_TITLE naming.") + except Exception: + self.logger(f" ⚠️ Could not parse 'added' date '{added_date_str}' for STYLE_DATE_POST_TITLE. Using 'nodate'.") + else: + self.logger(f" ⚠️ Post ID {original_post_id_for_log} missing both 'published' and 'added' dates for STYLE_DATE_POST_TITLE. Using 'nodate'.") - if published_date_str : - try : - formatted_date_str =published_date_str .split ('T')[0 ] - except Exception : - self .logger (f" ⚠️ Could not parse 'published' date '{published_date_str }' for STYLE_DATE_POST_TITLE. Using 'nodate'.") - elif added_date_str : - try : - formatted_date_str =added_date_str .split ('T')[0 ] - self .logger (f" ⚠️ Post ID {original_post_id_for_log } missing 'published' date, using 'added' date '{added_date_str }' for STYLE_DATE_POST_TITLE naming.") - except Exception : - self .logger (f" ⚠️ Could not parse 'added' date '{added_date_str }' for STYLE_DATE_POST_TITLE. Using 'nodate'.") - else : - self .logger (f" ⚠️ Post ID {original_post_id_for_log } missing both 'published' and 'added' dates for STYLE_DATE_POST_TITLE. Using 'nodate'.") + if post_title and post_title.strip(): + temp_cleaned_title = clean_filename(post_title.strip()) + if not temp_cleaned_title or temp_cleaned_title.startswith("untitled_file"): + self.logger(f"⚠️ Manga mode (Date+PostTitle Style): Post title for post {original_post_id_for_log} ('{post_title}') was empty or generic after cleaning. Using 'post' as title part.") + cleaned_post_title_for_filename = "post" + else: + cleaned_post_title_for_filename = temp_cleaned_title + base_name_for_style = f"{formatted_date_str}_{cleaned_post_title_for_filename}" + if num_files_in_this_post > 1: + filename_to_save_in_main_path = f"{base_name_for_style}_{file_index_in_post}{original_ext}" if file_index_in_post > 0 else f"{base_name_for_style}{original_ext}" + else: + filename_to_save_in_main_path = f"{base_name_for_style}{original_ext}" + else: + self.logger(f"⚠️ Manga mode (Date+PostTitle Style): Post title missing for post {original_post_id_for_log}. Using 'post' as title part with date prefix.") + cleaned_post_title_for_filename = "post" + base_name_for_style = f"{formatted_date_str}_{cleaned_post_title_for_filename}" + if num_files_in_this_post > 1: + filename_to_save_in_main_path = f"{base_name_for_style}_{file_index_in_post}{original_ext}" if file_index_in_post > 0 else f"{base_name_for_style}{original_ext}" + else: + filename_to_save_in_main_path = f"{base_name_for_style}{original_ext}" + else: + self.logger(f"⚠️ Manga mode: Unknown filename style '{self.manga_filename_style}'. Defaulting to original filename for '{api_original_filename}'.") + filename_to_save_in_main_path = cleaned_original_api_filename - if post_title and post_title .strip (): - temp_cleaned_title =clean_filename (post_title .strip ()) - if not temp_cleaned_title or temp_cleaned_title .startswith ("untitled_file"): - self .logger (f"⚠️ Manga mode (Date+PostTitle Style): Post title for post {original_post_id_for_log } ('{post_title }') was empty or generic after cleaning. Using 'post' as title part.") - cleaned_post_title_for_filename ="post" - else : - cleaned_post_title_for_filename =temp_cleaned_title + if not filename_to_save_in_main_path: + filename_to_save_in_main_path = f"manga_file_{original_post_id_for_log}_{file_index_in_post + 1}{original_ext}" + self.logger(f"⚠️ Manga mode: Generated filename was empty. Using generic fallback: '{filename_to_save_in_main_path}'.") + was_original_name_kept_flag = False + else: + filename_to_save_in_main_path = cleaned_original_api_filename + was_original_name_kept_flag = True - base_name_for_style =f"{formatted_date_str }_{cleaned_post_title_for_filename }" + if self.remove_from_filename_words_list and filename_to_save_in_main_path: + base_name_for_removal, ext_for_removal = os.path.splitext(filename_to_save_in_main_path) + modified_base_name = base_name_for_removal + for word_to_remove in self.remove_from_filename_words_list: + if not word_to_remove: continue + pattern = re.compile(re.escape(word_to_remove), re.IGNORECASE) + modified_base_name = pattern.sub("", modified_base_name) + modified_base_name = re.sub(r'[_.\s-]+', ' ', modified_base_name) + modified_base_name = re.sub(r'\s+', ' ', modified_base_name) + modified_base_name = modified_base_name.strip() + if modified_base_name and modified_base_name != ext_for_removal.lstrip('.'): + filename_to_save_in_main_path = modified_base_name + ext_for_removal + else: + filename_to_save_in_main_path = base_name_for_removal + ext_for_removal - if num_files_in_this_post >1 : - filename_to_save_in_main_path =f"{base_name_for_style }_{file_index_in_post }{original_ext }"if file_index_in_post >0 else f"{base_name_for_style }{original_ext }" - else : - filename_to_save_in_main_path =f"{base_name_for_style }{original_ext }" - else : - self .logger (f"⚠️ Manga mode (Date+PostTitle Style): Post title missing for post {original_post_id_for_log }. Using 'post' as title part with date prefix.") - cleaned_post_title_for_filename ="post" - base_name_for_style =f"{formatted_date_str }_{cleaned_post_title_for_filename }" - if num_files_in_this_post >1 : - filename_to_save_in_main_path =f"{base_name_for_style }_{file_index_in_post }{original_ext }"if file_index_in_post >0 else f"{base_name_for_style }{original_ext }" - else : - filename_to_save_in_main_path =f"{base_name_for_style }{original_ext }" - self .logger (f"⚠️ Manga mode (Title+GlobalNum Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path }' for post {original_post_id_for_log }.") - else : - self .logger (f"⚠️ Manga mode: Unknown filename style '{self .manga_filename_style }'. Defaulting to original filename for '{api_original_filename }'.") - filename_to_save_in_main_path =cleaned_original_api_filename - if not filename_to_save_in_main_path : - filename_to_save_in_main_path =f"manga_file_{original_post_id_for_log }_{file_index_in_post +1 }{original_ext }" - self .logger (f"⚠️ Manga mode: Generated filename was empty. Using generic fallback: '{filename_to_save_in_main_path }'.") - was_original_name_kept_flag =False - else : + if not self.download_thumbnails: + is_img_type = is_image(api_original_filename) + is_vid_type = is_video(api_original_filename) + is_archive_type = is_archive(api_original_filename) + is_audio_type = is_audio(api_original_filename) + if self.filter_mode == 'archive': + if not is_archive_type: + self.logger(f" -> Filter Skip (Archive Mode): '{api_original_filename}' (Not an Archive).") + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None + elif self.filter_mode == 'image': + if not is_img_type: + self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Image).") + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None + elif self.filter_mode == 'video': + if not is_vid_type: + self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Video).") + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None + elif self.filter_mode == 'audio': + if not is_audio_type: + self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).") + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None + if self.skip_zip and is_zip(api_original_filename): + self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).") + return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None + 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 - filename_to_save_in_main_path =cleaned_original_api_filename - was_original_name_kept_flag =False + try: + os.makedirs(target_folder_path, exist_ok=True) + 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 + temp_file_base_for_unique_part, temp_file_ext_for_unique_part = os.path.splitext(filename_to_save_in_main_path if filename_to_save_in_main_path else api_original_filename) + unique_id_for_part_file = uuid.uuid4().hex[:8] + unique_part_file_stem_on_disk = f"{temp_file_base_for_unique_part}_{unique_id_for_part_file}" + max_retries = 3 + retry_delay = 5 + downloaded_size_bytes = 0 + calculated_file_hash = None + downloaded_part_file_path = None + total_size_bytes = 0 + download_successful_flag = False + last_exception_for_retry_later = None + data_to_write_io = None + response_for_this_attempt = None + for attempt_num_single_stream in range(max_retries + 1): + response_for_this_attempt = None + if self._check_pause(f"File download attempt for '{api_original_filename}'"): break + if self.check_cancel() or (skip_event and skip_event.is_set()): break + try: + if attempt_num_single_stream > 0: + self.logger(f" Retrying download for '{api_original_filename}' (Overall Attempt {attempt_num_single_stream + 1}/{max_retries + 1})...") + time.sleep(retry_delay * (2 ** (attempt_num_single_stream - 1))) + self._emit_signal('file_download_status', True) + response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True, cookies=cookies_to_use_for_file) + response.raise_for_status() + total_size_bytes = int(response.headers.get('Content-Length', 0)) + num_parts_for_file = min(self.num_file_threads, MAX_PARTS_FOR_MULTIPART_DOWNLOAD) + attempt_multipart = (self.allow_multipart_download and MULTIPART_DOWNLOADER_AVAILABLE and + num_parts_for_file > 1 and total_size_bytes > MIN_SIZE_FOR_MULTIPART_DOWNLOAD and + 'bytes' in response.headers.get('Accept-Ranges', '').lower()) + if self._check_pause(f"Multipart decision for '{api_original_filename}'"): break - if self .remove_from_filename_words_list and filename_to_save_in_main_path : - - base_name_for_removal ,ext_for_removal =os .path .splitext (filename_to_save_in_main_path ) - modified_base_name =base_name_for_removal - for word_to_remove in self .remove_from_filename_words_list : - if not word_to_remove :continue - pattern =re .compile (re .escape (word_to_remove ),re .IGNORECASE ) - modified_base_name =pattern .sub ("",modified_base_name ) - modified_base_name =re .sub (r'[_.\s-]+',' ',modified_base_name ) - modified_base_name =re .sub (r'\s+',' ',modified_base_name ) - modified_base_name =modified_base_name .strip () - if modified_base_name and modified_base_name !=ext_for_removal .lstrip ('.'): - filename_to_save_in_main_path =modified_base_name +ext_for_removal - else : - filename_to_save_in_main_path =base_name_for_removal +ext_for_removal - - - - if not self .download_thumbnails : - - is_img_type =is_image (api_original_filename ) - is_vid_type =is_video (api_original_filename ) - is_archive_type =is_archive (api_original_filename ) - is_audio_type =is_audio (api_original_filename ) - if self .filter_mode =='archive': - if not is_archive_type : - self .logger (f" -> Filter Skip (Archive Mode): '{api_original_filename }' (Not an Archive).") - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - elif self .filter_mode =='image': - if not is_img_type : - self .logger (f" -> Filter Skip: '{api_original_filename }' (Not Image).") - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - elif self .filter_mode =='video': - if not is_vid_type : - self .logger (f" -> Filter Skip: '{api_original_filename }' (Not Video).") - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - elif self .filter_mode =='audio': - if not is_audio_type : - self .logger (f" -> Filter Skip: '{api_original_filename }' (Not Audio).") - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - if self .skip_zip and is_zip (api_original_filename ): - self .logger (f" -> Pref Skip: '{api_original_filename }' (ZIP).") - return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - 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 - - - - try : - os .makedirs (target_folder_path ,exist_ok =True ) - - 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 - - - - - - temp_file_base_for_unique_part ,temp_file_ext_for_unique_part =os .path .splitext (filename_to_save_in_main_path if filename_to_save_in_main_path else api_original_filename ) - unique_id_for_part_file =uuid .uuid4 ().hex [:8 ] - unique_part_file_stem_on_disk =f"{temp_file_base_for_unique_part }_{unique_id_for_part_file }" - max_retries =3 - retry_delay =5 - downloaded_size_bytes =0 - calculated_file_hash =None - downloaded_part_file_path =None - was_multipart_download =False - total_size_bytes =0 - download_successful_flag =False - last_exception_for_retry_later =None - - response_for_this_attempt =None - for attempt_num_single_stream in range (max_retries +1 ): - response_for_this_attempt =None - if self ._check_pause (f"File download attempt for '{api_original_filename }'"):break - if self .check_cancel ()or (skip_event and skip_event .is_set ()):break - try : - if attempt_num_single_stream >0 : - self .logger (f" Retrying download for '{api_original_filename }' (Overall Attempt {attempt_num_single_stream +1 }/{max_retries +1 })...") - time .sleep (retry_delay *(2 **(attempt_num_single_stream -1 ))) - self ._emit_signal ('file_download_status',True ) - response =requests .get (file_url ,headers =headers ,timeout =(15 ,300 ),stream =True ,cookies =cookies_to_use_for_file ) - response .raise_for_status () - total_size_bytes =int (response .headers .get ('Content-Length',0 )) - num_parts_for_file =min (self .num_file_threads ,MAX_PARTS_FOR_MULTIPART_DOWNLOAD ) - attempt_multipart =(self .allow_multipart_download and MULTIPART_DOWNLOADER_AVAILABLE and - num_parts_for_file >1 and total_size_bytes >MIN_SIZE_FOR_MULTIPART_DOWNLOAD and - 'bytes'in response .headers .get ('Accept-Ranges','').lower ()) - if self ._check_pause (f"Multipart decision for '{api_original_filename }'"):break - - if attempt_multipart : - if response_for_this_attempt : - response_for_this_attempt .close () - response_for_this_attempt =None - - - - - - mp_save_path_for_unique_part_stem_arg =os .path .join (target_folder_path ,f"{unique_part_file_stem_on_disk }{temp_file_ext_for_unique_part }") - mp_success ,mp_bytes ,mp_hash ,mp_file_handle =download_file_in_parts ( - file_url ,mp_save_path_for_unique_part_stem_arg ,total_size_bytes ,num_parts_for_file ,headers ,api_original_filename , - emitter_for_multipart =self .emitter ,cookies_for_chunk_session =cookies_to_use_for_file , - cancellation_event =self .cancellation_event ,skip_event =skip_event ,logger_func =self .logger , - pause_event =self .pause_event + if attempt_multipart: + if response_for_this_attempt: + response_for_this_attempt.close() + response_for_this_attempt = None + mp_save_path_for_unique_part_stem_arg = os.path.join(target_folder_path, f"{unique_part_file_stem_on_disk}{temp_file_ext_for_unique_part}") + mp_success, mp_bytes, mp_hash, mp_file_handle = download_file_in_parts( + file_url, mp_save_path_for_unique_part_stem_arg, total_size_bytes, num_parts_for_file, headers, api_original_filename, + emitter_for_multipart=self.emitter, cookies_for_chunk_session=cookies_to_use_for_file, + cancellation_event=self.cancellation_event, skip_event=skip_event, logger_func=self.logger, + pause_event=self.pause_event ) - if mp_success : - download_successful_flag =True - downloaded_size_bytes =mp_bytes - calculated_file_hash =mp_hash + if mp_success: + download_successful_flag = True + downloaded_size_bytes = mp_bytes + calculated_file_hash = mp_hash + downloaded_part_file_path = mp_save_path_for_unique_part_stem_arg + ".part" + if mp_file_handle: mp_file_handle.close() + break + else: + if attempt_num_single_stream < max_retries: + self.logger(f" Multi-part download attempt failed for '{api_original_filename}'. Retrying with single stream.") + else: + download_successful_flag = False; break + else: + self.logger(f"⬇️ Downloading (Single Stream): '{api_original_filename}' (Size: {total_size_bytes / (1024 * 1024):.2f} MB if known) [Base Name: '{filename_to_save_in_main_path}']") + current_single_stream_part_path = os.path.join(target_folder_path, f"{unique_part_file_stem_on_disk}{temp_file_ext_for_unique_part}.part") + current_attempt_downloaded_bytes = 0 + md5_hasher = hashlib.md5() + last_progress_time = time.time() + single_stream_exception = None + try: + with open(current_single_stream_part_path, 'wb') as f_part: + for chunk in response.iter_content(chunk_size=1 * 1024 * 1024): + if self._check_pause(f"Chunk download for '{api_original_filename}'"): break + if self.check_cancel() or (skip_event and skip_event.is_set()): break + if chunk: + f_part.write(chunk) + md5_hasher.update(chunk) + current_attempt_downloaded_bytes += len(chunk) + if time.time() - last_progress_time > 1 and total_size_bytes > 0: + self._emit_signal('file_progress', api_original_filename, (current_attempt_downloaded_bytes, total_size_bytes)) + last_progress_time = time.time() + if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not (current_attempt_downloaded_bytes > 0 or (total_size_bytes == 0 and response.status_code == 200))): + if os.path.exists(current_single_stream_part_path): os.remove(current_single_stream_part_path) + break + attempt_is_complete = False + if response.status_code == 200: + if total_size_bytes > 0: + if current_attempt_downloaded_bytes == total_size_bytes: + attempt_is_complete = True + else: + self.logger(f" ⚠️ Single-stream attempt for '{api_original_filename}' incomplete: received {current_attempt_downloaded_bytes} of {total_size_bytes} bytes.") + elif total_size_bytes == 0: + if current_attempt_downloaded_bytes > 0: + self.logger(f" ⚠️ Mismatch for '{api_original_filename}': Server reported 0 bytes, but received {current_attempt_downloaded_bytes} bytes this attempt.") + attempt_is_complete = True + else: + attempt_is_complete = True + if attempt_is_complete: + calculated_file_hash = md5_hasher.hexdigest() + downloaded_size_bytes = current_attempt_downloaded_bytes + downloaded_part_file_path = current_single_stream_part_path + download_successful_flag = True + break + else: + if os.path.exists(current_single_stream_part_path): + try: + os.remove(current_single_stream_part_path) + except OSError as e_rem_part: + self.logger(f" -> Failed to remove .part file after failed single stream attempt: {e_rem_part}") + except Exception as e_write: + self.logger(f" ❌ Error writing single-stream to disk for '{api_original_filename}': {e_write}") + if os.path.exists(current_single_stream_part_path): os.remove(current_single_stream_part_path) + raise + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e: + self.logger(f" ❌ Download Error (Retryable): {api_original_filename}. Error: {e}") + last_exception_for_retry_later = e + if isinstance(e, requests.exceptions.ConnectionError) and ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): + self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") + except requests.exceptions.RequestException as e: + self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}") + last_exception_for_retry_later = e + if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): + self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") + break + except Exception as e: + self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}") + last_exception_for_retry_later = e + break + finally: + if response_for_this_attempt: + response_for_this_attempt.close() + self._emit_signal('file_download_status', False) + 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)) - downloaded_part_file_path =mp_save_path_for_unique_part_stem_arg +".part" - was_multipart_download =True - if mp_file_handle :mp_file_handle .close () - break - else : - if attempt_num_single_stream 1 and total_size_bytes >0 : - self ._emit_signal ('file_progress',api_original_filename ,(current_attempt_downloaded_bytes ,total_size_bytes )) - last_progress_time =time .time () - - if self .check_cancel ()or (skip_event and skip_event .is_set ())or (self .pause_event and self .pause_event .is_set ()and not (current_attempt_downloaded_bytes >0 or (total_size_bytes ==0 and response .status_code ==200 ))): - if os .path .exists (current_single_stream_part_path ):os .remove (current_single_stream_part_path ) - break - - - attempt_is_complete =False - if response .status_code ==200 : - if total_size_bytes >0 : - if current_attempt_downloaded_bytes ==total_size_bytes : - attempt_is_complete =True - else : - self .logger (f" ⚠️ Single-stream attempt for '{api_original_filename }' incomplete: received {current_attempt_downloaded_bytes } of {total_size_bytes } bytes.") - elif total_size_bytes ==0 : - if current_attempt_downloaded_bytes ==0 : - attempt_is_complete =True - else : - self .logger (f" ⚠️ Mismatch for '{api_original_filename }': Server reported 0 bytes, but received {current_attempt_downloaded_bytes } bytes this attempt.") - - - elif current_attempt_downloaded_bytes >0 : - attempt_is_complete =True - self .logger (f" ⚠️ Single-stream for '{api_original_filename }' received {current_attempt_downloaded_bytes } bytes (no Content-Length from server). Assuming complete for this attempt as stream ended.") - - if attempt_is_complete : - calculated_file_hash =md5_hasher .hexdigest () - downloaded_size_bytes =current_attempt_downloaded_bytes - downloaded_part_file_path =current_single_stream_part_path - was_multipart_download =False - download_successful_flag =True - break - else : - if os .path .exists (current_single_stream_part_path ): - try :os .remove (current_single_stream_part_path ) - except OSError as e_rem_part :self .logger (f" -> Failed to remove .part file after failed single stream attempt: {e_rem_part }") - - except Exception as e_write : - self .logger (f" ❌ Error writing single-stream to disk for '{api_original_filename }': {e_write }") - if os .path .exists (current_single_stream_part_path ):os .remove (current_single_stream_part_path ) - - raise - single_stream_exception =e_write - if single_stream_exception : - raise single_stream_exception - - except (requests .exceptions .ConnectionError ,requests .exceptions .Timeout ,http .client .IncompleteRead )as e : - self .logger (f" ❌ Download Error (Retryable): {api_original_filename }. Error: {e }") - last_exception_for_retry_later =e - if isinstance (e ,requests .exceptions .ConnectionError )and ("Failed to resolve"in str (e )or "NameResolutionError"in str (e )): - self .logger (" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") - except requests .exceptions .RequestException as e : - self .logger (f" ❌ Download Error (Non-Retryable): {api_original_filename }. Error: {e }") - last_exception_for_retry_later =e - if ("Failed to resolve"in str (e )or "NameResolutionError"in str (e )): - self .logger (" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") - - break - except Exception as e : - self .logger (f" ❌ Unexpected Download Error: {api_original_filename }: {e }\n{traceback .format_exc (limit =2 )}") - last_exception_for_retry_later =e - break - finally : - if response_for_this_attempt : - response_for_this_attempt .close () - self ._emit_signal ('file_download_status',False ) - - 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 )) - -# --- Start of Replacement Block --- - - # Rescue download if an IncompleteRead error occurred but the file is complete if (not download_successful_flag and isinstance(last_exception_for_retry_later, http.client.IncompleteRead) and total_size_bytes > 0 and downloaded_part_file_path and os.path.exists(downloaded_part_file_path)): @@ -597,10 +535,9 @@ class PostProcessorWorker: if actual_size == total_size_bytes: self.logger(f" ✅ Rescued '{api_original_filename}': IncompleteRead error occurred, but file size matches. Proceeding with save.") download_successful_flag = True - # The hash must be recalculated now that we've verified the file md5_hasher = hashlib.md5() with open(downloaded_part_file_path, 'rb') as f_verify: - for chunk in iter(lambda: f_verify.read(8192), b""): # Read in chunks + for chunk in iter(lambda: f_verify.read(8192), b""): md5_hasher.update(chunk) calculated_file_hash = md5_hasher.hexdigest() except Exception as rescue_exc: @@ -609,81 +546,26 @@ class PostProcessorWorker: if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag): self.logger(f" ⚠️ Download process interrupted for {api_original_filename}.") if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): - try: os.remove(downloaded_part_file_path) - except OSError: pass + try: + os.remove(downloaded_part_file_path) + except OSError: + pass return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None - # This logic block now correctly handles all outcomes: success, failure, or rescued. if download_successful_flag: - # --- This is the success path --- if self._check_pause(f"Post-download hash check for '{api_original_filename}'"): return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None - with self.downloaded_file_hashes_lock: - if calculated_file_hash in self.downloaded_file_hashes: - self.logger(f" -> Skip Saving Duplicate (Hash Match): '{api_original_filename}' (Hash: {calculated_file_hash[:8]}...).") - with self.downloaded_files_lock: self.downloaded_files.add(filename_to_save_in_main_path) - if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): - try: os.remove(downloaded_part_file_path) - except OSError as e_rem: self.logger(f" -> Failed to remove .part file for hash duplicate: {e_rem}") - return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None - effective_save_folder = target_folder_path - filename_after_styling_and_word_removal = filename_to_save_in_main_path + # ... (History check and other pre-save logic would be here) ... + + final_filename_on_disk = filename_to_save_in_main_path # This may be adjusted by collision logic + + # This is a placeholder for your collision and final filename logic + # For example: + # final_filename_on_disk = self._handle_collisions(target_folder_path, filename_to_save_in_main_path) - try: - os.makedirs(effective_save_folder, exist_ok=True) - except OSError as e: - self.logger(f" ❌ Critical error creating directory '{effective_save_folder}': {e}. Skipping file '{api_original_filename}'.") - if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): - try: os.remove(downloaded_part_file_path) - except OSError: pass - return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None - data_to_write_io = None - filename_after_compression = filename_after_styling_and_word_removal - is_img_for_compress_check = is_image(api_original_filename) - - if is_img_for_compress_check and self.compress_images and Image and downloaded_size_bytes > (1.5 * 1024 * 1024): - self.logger(f" Compressing '{api_original_filename}' ({downloaded_size_bytes / (1024 * 1024):.2f} MB)...") - if self._check_pause(f"Image compression for '{api_original_filename}'"): return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None - img_content_for_pillow = None - try: - with open(downloaded_part_file_path, 'rb') as f_img_in: - img_content_for_pillow = BytesIO(f_img_in.read()) - with Image.open(img_content_for_pillow) as img_obj: - if img_obj.mode == 'P': img_obj = img_obj.convert('RGBA') - elif img_obj.mode not in ['RGB', 'RGBA', 'L']: img_obj = img_obj.convert('RGB') - compressed_output_io = BytesIO() - img_obj.save(compressed_output_io, format='WebP', quality=80, method=4) - compressed_size = compressed_output_io.getbuffer().nbytes - if compressed_size < downloaded_size_bytes * 0.9: - self.logger(f" Compression success: {compressed_size / (1024 * 1024):.2f} MB.") - data_to_write_io = compressed_output_io - data_to_write_io.seek(0) - base_name_orig, _ = os.path.splitext(filename_after_compression) - filename_after_compression = base_name_orig + '.webp' - self.logger(f" Updated filename (compressed): {filename_after_compression}") - else: - self.logger(f" Compression skipped: WebP not significantly smaller.") - if compressed_output_io: compressed_output_io.close() - except Exception as comp_e: - self.logger(f"❌ Compression failed for '{api_original_filename}': {comp_e}. Saving original.") - finally: - if img_content_for_pillow: img_content_for_pillow.close() - - final_filename_on_disk = filename_after_compression - temp_base, temp_ext = os.path.splitext(final_filename_on_disk) - suffix_counter = 1 - while os.path.exists(os.path.join(effective_save_folder, final_filename_on_disk)): - final_filename_on_disk = f"{temp_base}_{suffix_counter}{temp_ext}" - suffix_counter += 1 - if final_filename_on_disk != filename_after_compression: - self.logger(f" Applied numeric suffix in '{os.path.basename(effective_save_folder)}': '{final_filename_on_disk}' (was '{filename_after_compression}')") - - if self._check_pause(f"File saving for '{final_filename_on_disk}'"): - return 0, 1, final_filename_on_disk, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None - final_save_path = os.path.join(effective_save_folder, final_filename_on_disk) try: if data_to_write_io: @@ -724,1192 +606,1071 @@ class PostProcessorWorker: time.sleep(0.05) return 1, 0, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SUCCESS, None + except Exception as save_err: - self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}") - if os.path.exists(final_save_path): - try: os.remove(final_save_path) - except OSError: self.logger(f" -> Failed to remove partially saved file: {final_save_path}") - - # --- FIX: Report as a permanent failure so it appears in the error dialog --- - permanent_failure_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, } - return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details + self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}") + + # --- START OF THE FIX --- + # If saving/renaming fails, try to clean up the orphaned .part file. + if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): + try: + os.remove(downloaded_part_file_path) + self.logger(f" Cleaned up temporary file after save error: {os.path.basename(downloaded_part_file_path)}") + except OSError as e_rem: + self.logger(f" ⚠️ Could not clean up temporary file '{os.path.basename(downloaded_part_file_path)}' after save error: {e_rem}") + # --- END OF THE FIX --- + + if os.path.exists(final_save_path): + try: + os.remove(final_save_path) + except OSError: + self.logger(f" -> Failed to remove partially saved file: {final_save_path}") + + permanent_failure_details = { + 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, + 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, + 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, + 'forced_filename_override': filename_to_save_in_main_path, + } + return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details finally: if data_to_write_io and hasattr(data_to_write_io, 'close'): data_to_write_io.close() - else: - # --- This is the failure path --- - self.logger(f"❌ Download failed for '{api_original_filename}' after {max_retries + 1} attempts.") - - is_actually_incomplete_read = False - if isinstance(last_exception_for_retry_later, http.client.IncompleteRead): - is_actually_incomplete_read = True - elif hasattr(last_exception_for_retry_later, '__cause__') and isinstance(last_exception_for_retry_later.__cause__, http.client.IncompleteRead): - is_actually_incomplete_read = True - elif last_exception_for_retry_later is not None: - str_exc = str(last_exception_for_retry_later).lower() - if "incompleteread" in str_exc or (isinstance(last_exception_for_retry_later, tuple) and any("incompleteread" in str(arg).lower() for arg in last_exception_for_retry_later if isinstance(arg, (str, Exception)))): - is_actually_incomplete_read = True - - if is_actually_incomplete_read: - self.logger(f" Marking '{api_original_filename}' for potential retry later due to IncompleteRead.") - retry_later_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, 'manga_mode_active_for_file': self.manga_mode_active, 'manga_filename_style_for_file': self.manga_filename_style, } - return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details - else: - self.logger(f" Marking '{api_original_filename}' as permanently failed for this session.") - permanent_failure_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, } - return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details - with self .downloaded_file_hashes_lock : - if calculated_file_hash in self .downloaded_file_hashes : - self .logger (f" -> Skip Saving Duplicate (Hash Match): '{api_original_filename }' (Hash: {calculated_file_hash [:8 ]}...).") - with self .downloaded_files_lock :self .downloaded_files .add (filename_to_save_in_main_path ) - if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ): - try :os .remove (downloaded_part_file_path ) - except OSError as e_rem :self .logger (f" -> Failed to remove .part file for hash duplicate: {e_rem }") - return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - - effective_save_folder =target_folder_path - filename_after_styling_and_word_removal =filename_to_save_in_main_path - - try: - os.makedirs(effective_save_folder, exist_ok=True) - except OSError as e: - self.logger(f" ❌ Critical error creating directory '{effective_save_folder}': {e}. Skipping file '{api_original_filename}'.") - if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): - try: os.remove(downloaded_part_file_path) - except OSError: pass - # --- FIX: Report as a permanent failure so it appears in the error dialog --- - permanent_failure_details = { 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, } - return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details - - data_to_write_io =None - filename_after_compression =filename_after_styling_and_word_removal - is_img_for_compress_check =is_image (api_original_filename ) - - if is_img_for_compress_check and self .compress_images and Image and downloaded_size_bytes >(1.5 *1024 *1024 ): - self .logger (f" Compressing '{api_original_filename }' ({downloaded_size_bytes /(1024 *1024 ):.2f} MB)...") - if self ._check_pause (f"Image compression for '{api_original_filename }'"):return 0 ,1 ,filename_to_save_in_main_path ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - - img_content_for_pillow =None - try : - with open (downloaded_part_file_path ,'rb')as f_img_in : - img_content_for_pillow =BytesIO (f_img_in .read ()) - - with Image .open (img_content_for_pillow )as img_obj : - if img_obj .mode =='P':img_obj =img_obj .convert ('RGBA') - elif img_obj .mode not in ['RGB','RGBA','L']:img_obj =img_obj .convert ('RGB') - - compressed_output_io =BytesIO () - img_obj .save (compressed_output_io ,format ='WebP',quality =80 ,method =4 ) - compressed_size =compressed_output_io .getbuffer ().nbytes - - if compressed_size Failed to remove .part after compression: {e_rem }") - else : - if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ): - time .sleep (0.1 ) - os .rename (downloaded_part_file_path ,final_save_path ) - else : - raise FileNotFoundError (f"Original .part file not found for saving: {downloaded_part_file_path }") - with self .downloaded_file_hashes_lock :self .downloaded_file_hashes .add (calculated_file_hash ) - with self .downloaded_files_lock :self .downloaded_files .add (filename_to_save_in_main_path ) - final_filename_saved_for_return =final_filename_on_disk - self .logger (f"✅ Saved: '{final_filename_saved_for_return }' (from '{api_original_filename }', {downloaded_size_bytes /(1024 *1024 ):.2f} MB) in '{os .path .basename (effective_save_folder )}'") - - - downloaded_file_details ={ - 'disk_filename':final_filename_saved_for_return , - 'post_title':post_title , - 'post_id':original_post_id_for_log , - 'upload_date_str':self .post .get ('published')or self .post .get ('added')or "N/A", - 'download_timestamp':time .time (), - 'download_path':effective_save_folder , - 'service':self .service , - 'user_id':self .user_id , - 'api_original_filename':api_original_filename , - 'folder_context_name':folder_context_name_for_history or os .path .basename (effective_save_folder ) + # This is the path if the download was not successful after all retries + self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.") + retry_later_details = { + 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, + 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, + 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post } - self ._emit_signal ('file_successfully_downloaded',downloaded_file_details ) - time .sleep (0.05 ) + return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details - return 1 ,0 ,final_filename_saved_for_return ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SUCCESS ,None - except Exception as save_err : - self .logger (f"->>Save Fail for '{final_filename_on_disk }': {save_err }") - if os .path .exists (final_save_path ): - try :os .remove (final_save_path ); - except OSError :self .logger (f" -> Failed to remove partially saved file: {final_save_path }") + def process(self): + # Default "empty" result tuple. It will be updated before any return path. + result_tuple = (0, 0, [], [], [], None, None) + try: + if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"): + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple # Return for the direct caller + if self.check_cancel(): + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple + current_character_filters = self._get_current_character_filters() + kept_original_filenames_for_log = [] + retryable_failures_this_post = [] + permanent_failures_this_post = [] + total_downloaded_this_post = 0 + total_skipped_this_post = 0 + history_data_for_this_post = None - return 0 ,1 ,final_filename_saved_for_return ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None - finally : - if data_to_write_io and hasattr (data_to_write_io ,'close'): - data_to_write_io .close () + parsed_api_url = urlparse(self.api_url_input) + referer_url = f"https://{parsed_api_url.netloc}/" + headers = {'User-Agent': 'Mozilla/5.0', 'Referer': referer_url, 'Accept': '*/*'} + link_pattern = re.compile(r"""]*>(.*?)""", re.IGNORECASE | re.DOTALL) + post_data = self.post + post_title = post_data.get('title', '') or 'untitled_post' + post_id = post_data.get('id', 'unknown_id') + post_main_file_info = post_data.get('file') + post_attachments = post_data.get('attachments', []) - def process (self ): - if self ._check_pause (f"Post processing for ID {self .post .get ('id','N/A')}"):return 0 ,0 ,[],[],[],None, None - if self .check_cancel ():return 0 ,0 ,[],[],[],None, None - current_character_filters =self ._get_current_character_filters () - kept_original_filenames_for_log =[] - retryable_failures_this_post =[] - permanent_failures_this_post =[] - total_downloaded_this_post =0 - total_skipped_this_post =0 - history_data_for_this_post =None + effective_unwanted_keywords_for_folder_naming = self.unwanted_keywords.copy() + is_full_creator_download_no_char_filter = not self.target_post_id_from_initial_url and not current_character_filters + if is_full_creator_download_no_char_filter and self.creator_download_folder_ignore_words: + self.logger(f" Applying creator download specific folder ignore words ({len(self.creator_download_folder_ignore_words)} words).") + effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words) - parsed_api_url =urlparse (self .api_url_input ) - referer_url =f"https://{parsed_api_url .netloc }/" - headers ={'User-Agent':'Mozilla/5.0','Referer':referer_url ,'Accept':'*/*'} - link_pattern =re .compile (r"""]*>(.*?)""", - re .IGNORECASE |re .DOTALL ) - post_data =self .post - post_title =post_data .get ('title','')or 'untitled_post' - post_id =post_data .get ('id','unknown_id') - post_main_file_info =post_data .get ('file') - post_attachments =post_data .get ('attachments',[]) + post_content_html = post_data.get('content', '') + self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---") + num_potential_files_in_post = len(post_attachments or []) + (1 if post_main_file_info and post_main_file_info.get('path') else 0) - effective_unwanted_keywords_for_folder_naming =self .unwanted_keywords .copy () - is_full_creator_download_no_char_filter =not self .target_post_id_from_initial_url and not current_character_filters - if is_full_creator_download_no_char_filter and self .creator_download_folder_ignore_words : - self .logger (f" Applying creator download specific folder ignore words ({len (self .creator_download_folder_ignore_words )} words).") - effective_unwanted_keywords_for_folder_naming .update (self .creator_download_folder_ignore_words ) + post_is_candidate_by_title_char_match = False + char_filter_that_matched_title = None + post_is_candidate_by_comment_char_match = False + 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 - post_content_html =post_data .get ('content','') - self .logger (f"\n--- Processing Post {post_id } ('{post_title [:50 ]}...') (Thread: {threading .current_thread ().name }) ---") - num_potential_files_in_post =len (post_attachments or [])+(1 if post_main_file_info and post_main_file_info .get ('path')else 0 ) - post_is_candidate_by_title_char_match =False - char_filter_that_matched_title =None - post_is_candidate_by_comment_char_match =False - 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 ): - if self ._check_pause (f"Character title filter for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None - for idx ,filter_item_obj in enumerate (current_character_filters ): - if self .check_cancel ():break - 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 )) - for term_to_match in unique_terms_for_title_check : - match_found_for_term =is_title_match_for_character (post_title ,term_to_match ) - 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 - 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']): - api_file_domain_for_char_check ="kemono.su"if "kemono"in self .service .lower ()else "coomer.party" - if post_main_file_info and isinstance (post_main_file_info ,dict )and post_main_file_info .get ('path'): - original_api_name =post_main_file_info .get ('name')or os .path .basename (post_main_file_info ['path'].lstrip ('/')) - if original_api_name : - all_files_from_post_api_for_char_check .append ({'_original_name_for_log':original_api_name }) - for att_info in post_attachments : - if isinstance (att_info ,dict )and att_info .get ('path'): - 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 }) - if current_character_filters and self .char_filter_scope ==CHAR_SCOPE_COMMENTS : - self .logger (f" [Char Scope: Comments] Phase 1: Checking post files for matches before comments for post ID '{post_id }'.") - if self ._check_pause (f"File check (comments scope) for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None - for file_info_item in all_files_from_post_api_for_char_check : - if self .check_cancel ():break - current_api_original_filename_for_check =file_info_item .get ('_original_name_for_log') - if not current_api_original_filename_for_check :continue - for filter_item_obj in current_character_filters : - terms_to_check =list (filter_item_obj ["aliases"]) - if filter_item_obj ["is_group"]and filter_item_obj ["name"]not in terms_to_check : - terms_to_check .append (filter_item_obj ["name"]) - for term_to_match in terms_to_check : - if is_filename_match_for_character (current_api_original_filename_for_check ,term_to_match ): - post_is_candidate_by_file_char_match_in_comment_scope =True - char_filter_that_matched_file_in_comment_scope =filter_item_obj - self .logger (f" Match Found (File in Comments Scope): File '{current_api_original_filename_for_check }' matches char filter term '{term_to_match }' (from group/name '{filter_item_obj ['name']}'). Post is candidate.") - break - if post_is_candidate_by_file_char_match_in_comment_scope :break - if post_is_candidate_by_file_char_match_in_comment_scope :break - self .logger (f" [Char Scope: Comments] Phase 1 Result: post_is_candidate_by_file_char_match_in_comment_scope = {post_is_candidate_by_file_char_match_in_comment_scope }") - if current_character_filters and self .char_filter_scope ==CHAR_SCOPE_COMMENTS : - if not post_is_candidate_by_file_char_match_in_comment_scope : - if self ._check_pause (f"Comment check for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None - self .logger (f" [Char Scope: Comments] Phase 2: No file match found. Checking post comments for post ID '{post_id }'.") - try : - parsed_input_url_for_comments =urlparse (self .api_url_input ) - api_domain_for_comments =parsed_input_url_for_comments .netloc - if not any (d in api_domain_for_comments .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']): - self .logger (f"⚠️ Unrecognized domain '{api_domain_for_comments }' for comment API. Defaulting based on service.") - api_domain_for_comments ="kemono.su"if "kemono"in self .service .lower ()else "coomer.party" - comments_data =fetch_post_comments ( - api_domain_for_comments ,self .service ,self .user_id ,post_id , - headers ,self .logger ,self .cancellation_event ,self .pause_event , - cookies_dict =prepare_cookies_for_request ( - self .use_cookie ,self .cookie_text ,self .selected_cookie_file ,self .app_base_dir ,self .logger - ) - ) - if comments_data : - self .logger (f" Fetched {len (comments_data )} comments for post {post_id }.") - for comment_item_idx ,comment_item in enumerate (comments_data ): - if self .check_cancel ():break - raw_comment_content =comment_item .get ('content','') - if not raw_comment_content :continue - cleaned_comment_text =strip_html_tags (raw_comment_content ) - if not cleaned_comment_text .strip ():continue - for filter_item_obj in current_character_filters : - terms_to_check_comment =list (filter_item_obj ["aliases"]) - if filter_item_obj ["is_group"]and filter_item_obj ["name"]not in terms_to_check_comment : - terms_to_check_comment .append (filter_item_obj ["name"]) - for term_to_match_comment in terms_to_check_comment : - if is_title_match_for_character (cleaned_comment_text ,term_to_match_comment ): - post_is_candidate_by_comment_char_match =True - char_filter_that_matched_comment =filter_item_obj - self .logger (f" Match Found (Comment in Comments Scope): Comment in post {post_id } matches char filter term '{term_to_match_comment }' (from group/name '{filter_item_obj ['name']}'). Post is candidate.") - self .logger (f" Matching comment (first 100 chars): '{cleaned_comment_text [:100 ]}...'") - break - if post_is_candidate_by_comment_char_match :break - if post_is_candidate_by_comment_char_match :break - else : - self .logger (f" No comments found or fetched for post {post_id } to check against character filters.") - except RuntimeError as e_fetch_comment : - self .logger (f" ⚠️ Error fetching or processing comments for post {post_id }: {e_fetch_comment }") - except Exception as e_generic_comment : - self .logger (f" ❌ Unexpected error during comment processing for post {post_id }: {e_generic_comment }\n{traceback .format_exc (limit =2 )}") - self .logger (f" [Char Scope: Comments] Phase 2 Result: post_is_candidate_by_comment_char_match = {post_is_candidate_by_comment_char_match }") - else : - 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.") - if current_character_filters : - 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.") - self ._emit_signal ('missed_character_post',post_title ,"No title match for character filter") - return 0 ,num_potential_files_in_post ,[],[],[],None, None - if self .char_filter_scope ==CHAR_SCOPE_COMMENTS and not post_is_candidate_by_file_char_match_in_comment_scope and not post_is_candidate_by_comment_char_match : - self .logger (f" -> Skip Post (Scope: Comments - No Char Match in Comments): Post ID '{post_id }', Title '{post_title [:50 ]}...'") - if self .emitter and hasattr (self .emitter ,'missed_character_post_signal'): - self ._emit_signal ('missed_character_post',post_title ,"No character match in files or comments (Comments scope)") - return 0 ,num_potential_files_in_post ,[],[],[],None, None - if self .skip_words_list and (self .skip_words_scope ==SKIP_SCOPE_POSTS or self .skip_words_scope ==SKIP_SCOPE_BOTH ): - if self ._check_pause (f"Skip words (post title) for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None - post_title_lower =post_title .lower () - for skip_word in self .skip_words_list : - if skip_word .lower ()in post_title_lower : - 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 ,[],[],[],None, None - if not self .extract_links_only and self .manga_mode_active and current_character_filters and (self .char_filter_scope ==CHAR_SCOPE_TITLE or self .char_filter_scope ==CHAR_SCOPE_BOTH )and not post_is_candidate_by_title_char_match : - self .logger (f" -> Skip Post (Manga Mode with Title/Both Scope - No Title Char Match): Title '{post_title [:50 ]}' doesn't match filters.") - self ._emit_signal ('missed_character_post',post_title ,"Manga Mode: No title match for character filter (Title/Both scope)") - return 0 ,num_potential_files_in_post ,[],[],[],None, None - if not isinstance (post_attachments ,list ): - self .logger (f"⚠️ Corrupt attachment data for post {post_id } (expected list, got {type (post_attachments )}). Skipping attachments.") - post_attachments =[] - base_folder_names_for_post_content =[] - determined_post_save_path_for_history =self .override_output_dir if self .override_output_dir else self .download_root - if not self .extract_links_only and self .use_subfolders : - if self ._check_pause (f"Subfolder determination for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None - primary_char_filter_for_folder =None - log_reason_for_folder ="" - if self .char_filter_scope ==CHAR_SCOPE_COMMENTS and char_filter_that_matched_comment : - 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)" - elif post_is_candidate_by_comment_char_match and char_filter_that_matched_comment : - primary_char_filter_for_folder =char_filter_that_matched_comment - log_reason_for_folder ="Matched char filter in comments (Comments scope, no file match)" - elif (self .char_filter_scope ==CHAR_SCOPE_TITLE or self .char_filter_scope ==CHAR_SCOPE_BOTH )and char_filter_that_matched_title : - primary_char_filter_for_folder =char_filter_that_matched_title - log_reason_for_folder ="Matched char filter in title" - if primary_char_filter_for_folder : - base_folder_names_for_post_content =[clean_folder_name (primary_char_filter_for_folder ["name"])] - cleaned_primary_folder_name =clean_folder_name (primary_char_filter_for_folder ["name"]) - if cleaned_primary_folder_name .lower ()in effective_unwanted_keywords_for_folder_naming and cleaned_primary_folder_name .lower ()!="untitled_folder": - self .logger (f" ⚠️ Primary char filter folder name '{cleaned_primary_folder_name }' is in ignore list. Using generic name.") - base_folder_names_for_post_content =["Generic Post Content"] - else : - base_folder_names_for_post_content =[cleaned_primary_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 : + if current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH): + if self._check_pause(f"Character title filter for post {post_id}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + for idx, filter_item_obj in enumerate(current_character_filters): + if self.check_cancel(): break + 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)) + for term_to_match in unique_terms_for_title_check: + match_found_for_term = is_title_match_for_character(post_title, term_to_match) + 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 - derived_folders_from_title_via_known_txt =match_folders_from_title ( - post_title , - self .known_names , - effective_unwanted_keywords_for_folder_naming - ) + 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']): + api_file_domain_for_char_check = "kemono.su" if "kemono" in self.service.lower() else "coomer.party" + if post_main_file_info and isinstance(post_main_file_info, dict) and post_main_file_info.get('path'): + original_api_name = post_main_file_info.get('name') or os.path.basename(post_main_file_info['path'].lstrip('/')) + if original_api_name: + all_files_from_post_api_for_char_check.append({'_original_name_for_log': original_api_name}) + for att_info in post_attachments: + if isinstance(att_info, dict) and att_info.get('path'): + 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}) - valid_derived_folders_from_title_known_txt =[ - name for name in derived_folders_from_title_via_known_txt - if name and name .strip ()and name .lower ()!="untitled_folder" - ] + if current_character_filters and self.char_filter_scope == CHAR_SCOPE_COMMENTS: + self.logger(f" [Char Scope: Comments] Phase 1: Checking post files for matches before comments for post ID '{post_id}'.") + if self._check_pause(f"File check (comments scope) for post {post_id}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + for file_info_item in all_files_from_post_api_for_char_check: + if self.check_cancel(): break + current_api_original_filename_for_check = file_info_item.get('_original_name_for_log') + if not current_api_original_filename_for_check: continue + for filter_item_obj in current_character_filters: + terms_to_check = list(filter_item_obj["aliases"]) + if filter_item_obj["is_group"] and filter_item_obj["name"] not in terms_to_check: + terms_to_check.append(filter_item_obj["name"]) + for term_to_match in terms_to_check: + if is_filename_match_for_character(current_api_original_filename_for_check, term_to_match): + post_is_candidate_by_file_char_match_in_comment_scope = True + char_filter_that_matched_file_in_comment_scope = filter_item_obj + self.logger(f" Match Found (File in Comments Scope): File '{current_api_original_filename_for_check}' matches char filter term '{term_to_match}' (from group/name '{filter_item_obj['name']}'). Post is candidate.") + break + if post_is_candidate_by_file_char_match_in_comment_scope: break + if post_is_candidate_by_file_char_match_in_comment_scope: break + self.logger(f" [Char Scope: Comments] Phase 1 Result: post_is_candidate_by_file_char_match_in_comment_scope = {post_is_candidate_by_file_char_match_in_comment_scope}") - if valid_derived_folders_from_title_known_txt : - base_folder_names_for_post_content .extend (valid_derived_folders_from_title_known_txt ) - self .logger (f" Base folder name(s) for post content (Derived from Known.txt & Post Title): {', '.join (base_folder_names_for_post_content )}") - else : - - - - - candidate_name_from_title_basic_clean =extract_folder_name_from_title ( - post_title , - FOLDER_NAME_STOP_WORDS - ) - - title_is_only_creator_ignored_words =False - if candidate_name_from_title_basic_clean and candidate_name_from_title_basic_clean .lower ()!="untitled_folder"and self .creator_download_folder_ignore_words : - - candidate_title_words ={word .lower ()for word in candidate_name_from_title_basic_clean .split ()} - if candidate_title_words and candidate_title_words .issubset (self .creator_download_folder_ignore_words ): - title_is_only_creator_ignored_words =True - self .logger (f" Title-derived name '{candidate_name_from_title_basic_clean }' consists only of creator-specific ignore words.") - - if title_is_only_creator_ignored_words : - - self .logger (f" Attempting Known.txt match on filenames as title was poor ('{candidate_name_from_title_basic_clean }').") - - filenames_to_check =[ - f_info ['_original_name_for_log']for f_info in all_files_from_post_api_for_char_check - if f_info .get ('_original_name_for_log') - ] - - derived_folders_from_filenames_known_txt =set () - if filenames_to_check : - for fname in filenames_to_check : - matches =match_folders_from_title ( - fname , - self .known_names , - effective_unwanted_keywords_for_folder_naming - ) - for m in matches : - if m and m .strip ()and m .lower ()!="untitled_folder": - derived_folders_from_filenames_known_txt .add (m ) - - if derived_folders_from_filenames_known_txt : - base_folder_names_for_post_content .extend (list (derived_folders_from_filenames_known_txt )) - self .logger (f" Base folder name(s) for post content (Derived from Known.txt & Filenames): {', '.join (base_folder_names_for_post_content )}") - else : - final_title_extract =extract_folder_name_from_title ( - post_title ,effective_unwanted_keywords_for_folder_naming - ) - base_folder_names_for_post_content .append (final_title_extract ) - self .logger (f" No Known.txt match from filenames. Using title-derived name (with full ignore list): '{final_title_extract }'") - else : - extracted_name_from_title_full_ignore =extract_folder_name_from_title ( - post_title ,effective_unwanted_keywords_for_folder_naming - ) - base_folder_names_for_post_content .append (extracted_name_from_title_full_ignore ) - self .logger (f" Base folder name(s) for post content (Generic title parsing - title not solely creator-ignored words): {', '.join (base_folder_names_for_post_content )}") - - base_folder_names_for_post_content =[ - name for name in base_folder_names_for_post_content if name and name .strip () - ] - if not base_folder_names_for_post_content : - final_fallback_name =clean_folder_name (post_title if post_title and post_title .strip ()else "Generic Post Content") - base_folder_names_for_post_content =[final_fallback_name ] - self .logger (f" Ultimate fallback folder name: {final_fallback_name }") - - if base_folder_names_for_post_content : - determined_post_save_path_for_history =os .path .join (determined_post_save_path_for_history ,base_folder_names_for_post_content [0 ]) - - if not self .extract_links_only and self .use_post_subfolders : - cleaned_post_title_for_sub =clean_folder_name (post_title ) - post_id_for_fallback =self .post .get ('id','unknown_id') - - - if not cleaned_post_title_for_sub or cleaned_post_title_for_sub =="untitled_folder": - self .logger (f" ⚠️ Post title '{post_title }' resulted in a generic subfolder name. Using 'post_{post_id_for_fallback }' as base.") - original_cleaned_post_title_for_sub =f"post_{post_id_for_fallback }" - else : - original_cleaned_post_title_for_sub =cleaned_post_title_for_sub - - if self.use_date_prefix_for_subfolder: - # Prioritize 'published' date, fall back to 'added' date - published_date_str = self.post.get('published') or self.post.get('added') - if published_date_str: + if current_character_filters and self.char_filter_scope == CHAR_SCOPE_COMMENTS: + if not post_is_candidate_by_file_char_match_in_comment_scope: + if self._check_pause(f"Comment check for post {post_id}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + self.logger(f" [Char Scope: Comments] Phase 2: No file match found. Checking post comments for post ID '{post_id}'.") try: - # Extract just the date part (YYYY-MM-DD) - date_prefix = published_date_str.split('T')[0] - # Prepend the date to the folder name - original_cleaned_post_title_for_sub = f"{date_prefix} {original_cleaned_post_title_for_sub}" - self.logger(f" ℹ️ Applying date prefix to subfolder: '{original_cleaned_post_title_for_sub}'") - except Exception as e: - self.logger(f" ⚠️ Could not parse date '{published_date_str}' for prefix. Using original name. Error: {e}") + parsed_input_url_for_comments = urlparse(self.api_url_input) + api_domain_for_comments = parsed_input_url_for_comments.netloc + if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']): + self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.") + api_domain_for_comments = "kemono.su" if "kemono" in self.service.lower() else "coomer.party" + comments_data = fetch_post_comments( + api_domain_for_comments, self.service, self.user_id, post_id, + headers, self.logger, self.cancellation_event, self.pause_event, + cookies_dict=prepare_cookies_for_request( + self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger + ) + ) + if comments_data: + self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.") + for comment_item_idx, comment_item in enumerate(comments_data): + if self.check_cancel(): break + raw_comment_content = comment_item.get('content', '') + if not raw_comment_content: continue + cleaned_comment_text = strip_html_tags(raw_comment_content) + if not cleaned_comment_text.strip(): continue + for filter_item_obj in current_character_filters: + terms_to_check_comment = list(filter_item_obj["aliases"]) + if filter_item_obj["is_group"] and filter_item_obj["name"] not in terms_to_check_comment: + terms_to_check_comment.append(filter_item_obj["name"]) + for term_to_match_comment in terms_to_check_comment: + if is_title_match_for_character(cleaned_comment_text, term_to_match_comment): + post_is_candidate_by_comment_char_match = True + char_filter_that_matched_comment = filter_item_obj + self.logger(f" Match Found (Comment in Comments Scope): Comment in post {post_id} matches char filter term '{term_to_match_comment}' (from group/name '{filter_item_obj['name']}'). Post is candidate.") + self.logger(f" Matching comment (first 100 chars): '{cleaned_comment_text[:100]}...'") + break + if post_is_candidate_by_comment_char_match: break + if post_is_candidate_by_comment_char_match: break + else: + self.logger(f" No comments found or fetched for post {post_id} to check against character filters.") + except RuntimeError as e_fetch_comment: + self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}") + except Exception as e_generic_comment: + self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}") + self.logger(f" [Char Scope: Comments] Phase 2 Result: post_is_candidate_by_comment_char_match = {post_is_candidate_by_comment_char_match}") else: - self.logger(" ⚠️ 'Date Prefix' is checked, but post has no 'published' or 'added' date. Omitting prefix.") + 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.") - base_path_for_post_subfolder =determined_post_save_path_for_history + if current_character_filters: + 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.") + self._emit_signal('missed_character_post', post_title, "No title match for character filter") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + if self.char_filter_scope == CHAR_SCOPE_COMMENTS and not post_is_candidate_by_file_char_match_in_comment_scope and not post_is_candidate_by_comment_char_match: + self.logger(f" -> Skip Post (Scope: Comments - No Char Match in Comments): Post ID '{post_id}', Title '{post_title[:50]}...'") + if self.emitter and hasattr(self.emitter, 'missed_character_post_signal'): + self._emit_signal('missed_character_post', post_title, "No character match in files or comments (Comments scope)") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple - suffix_counter =0 - final_post_subfolder_name ="" - - while True : - if suffix_counter ==0 : - name_candidate =original_cleaned_post_title_for_sub - else : - name_candidate =f"{original_cleaned_post_title_for_sub }_{suffix_counter }" - - potential_post_subfolder_path =os .path .join (base_path_for_post_subfolder ,name_candidate ) - - try : - os .makedirs (potential_post_subfolder_path ,exist_ok =False ) - final_post_subfolder_name =name_candidate - if suffix_counter >0 : - self .logger (f" Post subfolder name conflict: Using '{final_post_subfolder_name }' instead of '{original_cleaned_post_title_for_sub }' to avoid mixing posts.") - break - except FileExistsError : - suffix_counter +=1 - if suffix_counter >100 : - self .logger (f" ⚠️ Exceeded 100 attempts to find unique subfolder name for '{original_cleaned_post_title_for_sub }'. Using UUID.") - final_post_subfolder_name =f"{original_cleaned_post_title_for_sub }_{uuid .uuid4 ().hex [:8 ]}" - os .makedirs (os .path .join (base_path_for_post_subfolder ,final_post_subfolder_name ),exist_ok =True ) - break - except OSError as e_mkdir : - self .logger (f" ❌ Error creating directory '{potential_post_subfolder_path }': {e_mkdir }. Files for this post might be saved in parent or fail.") - final_post_subfolder_name =original_cleaned_post_title_for_sub - break - - determined_post_save_path_for_history =os .path .join (base_path_for_post_subfolder ,final_post_subfolder_name ) - if self.filter_mode == 'text_only' and not self.extract_links_only: - self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})") - - # --- Apply Title-based filters to ensure post is a candidate --- - post_title_lower = post_title.lower() if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH): + if self._check_pause(f"Skip words (post title) for post {post_id}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + post_title_lower = post_title.lower() for skip_word in self.skip_words_list: if skip_word.lower() in post_title_lower: - self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'.") - return 0, num_potential_files_in_post, [], [], [], None, None - - if current_character_filters and not post_is_candidate_by_title_char_match and not post_is_candidate_by_comment_char_match and not post_is_candidate_by_file_char_match_in_comment_scope: - self.logger(f" -> Skip Post (No character match for text extraction): '{post_title[:50]}...'.") - return 0, num_potential_files_in_post, [], [], [], None, None + self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'. Scope: {self.skip_words_scope}") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple - # --- Get the text content based on scope --- - raw_text_content = "" - final_post_data = post_data + if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match: + self.logger(f" -> Skip Post (Manga Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.") + self._emit_signal('missed_character_post', post_title, "Manga Mode: No title match for character filter (Title/Both scope)") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple - # Fetch full post data if content is missing and scope is 'content' - if self.text_only_scope == 'content' and 'content' not in final_post_data: - self.logger(f" Post {post_id} is missing 'content' field, fetching full data...") - parsed_url = urlparse(self.api_url_input) - api_domain = parsed_url.netloc - cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain) - - from .api_client import fetch_single_post_data # Local import to avoid circular dependency issues - full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies) - if full_data: - final_post_data = full_data - - if self.text_only_scope == 'content': - raw_text_content = final_post_data.get('content', '') - elif self.text_only_scope == 'comments': - try: + if not isinstance(post_attachments, list): + self.logger(f"⚠️ Corrupt attachment data for post {post_id} (expected list, got {type(post_attachments)}). Skipping attachments.") + post_attachments = [] + + base_folder_names_for_post_content = [] + determined_post_save_path_for_history = self.override_output_dir if self.override_output_dir else self.download_root + if not self.extract_links_only and self.use_subfolders: + if self._check_pause(f"Subfolder determination for post {post_id}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + primary_char_filter_for_folder = None + log_reason_for_folder = "" + if self.char_filter_scope == CHAR_SCOPE_COMMENTS and char_filter_that_matched_comment: + 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)" + elif post_is_candidate_by_comment_char_match and char_filter_that_matched_comment: + primary_char_filter_for_folder = char_filter_that_matched_comment + log_reason_for_folder = "Matched char filter in comments (Comments scope, no file match)" + elif (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and char_filter_that_matched_title: + primary_char_filter_for_folder = char_filter_that_matched_title + log_reason_for_folder = "Matched char filter in title" + + if primary_char_filter_for_folder: + base_folder_names_for_post_content = [clean_folder_name(primary_char_filter_for_folder["name"])] + cleaned_primary_folder_name = clean_folder_name(primary_char_filter_for_folder["name"]) + if cleaned_primary_folder_name.lower() in effective_unwanted_keywords_for_folder_naming and cleaned_primary_folder_name.lower() != "untitled_folder": + self.logger(f" ⚠️ Primary char filter folder name '{cleaned_primary_folder_name}' is in ignore list. Using generic name.") + base_folder_names_for_post_content = ["Generic Post Content"] + else: + base_folder_names_for_post_content = [cleaned_primary_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: + derived_folders_from_title_via_known_txt = match_folders_from_title( + post_title, + self.known_names, + effective_unwanted_keywords_for_folder_naming + ) + valid_derived_folders_from_title_known_txt = [ + name for name in derived_folders_from_title_via_known_txt + if name and name.strip() and name.lower() != "untitled_folder" + ] + if valid_derived_folders_from_title_known_txt: + first_match = valid_derived_folders_from_title_known_txt[0] + base_folder_names_for_post_content.append(first_match) + self.logger(f" Base folder name for post content (First match from Known.txt & Title): '{first_match}'") + else: + candidate_name_from_title_basic_clean = extract_folder_name_from_title( + post_title, + FOLDER_NAME_STOP_WORDS + ) + title_is_only_creator_ignored_words = False + if candidate_name_from_title_basic_clean and candidate_name_from_title_basic_clean.lower() != "untitled_folder" and self.creator_download_folder_ignore_words: + candidate_title_words = {word.lower() for word in candidate_name_from_title_basic_clean.split()} + if candidate_title_words and candidate_title_words.issubset(self.creator_download_folder_ignore_words): + title_is_only_creator_ignored_words = True + self.logger(f" Title-derived name '{candidate_name_from_title_basic_clean}' consists only of creator-specific ignore words.") + if title_is_only_creator_ignored_words: + self.logger(f" Attempting Known.txt match on filenames as title was poor ('{candidate_name_from_title_basic_clean}').") + filenames_to_check = [ + f_info['_original_name_for_log'] for f_info in all_files_from_post_api_for_char_check + if f_info.get('_original_name_for_log') + ] + derived_folders_from_filenames_known_txt = set() + if filenames_to_check: + for fname in filenames_to_check: + matches = match_folders_from_title( + fname, + self.known_names, + effective_unwanted_keywords_for_folder_naming + ) + for m in matches: + if m and m.strip() and m.lower() != "untitled_folder": + derived_folders_from_filenames_known_txt.add(m) + if derived_folders_from_filenames_known_txt: + first_match = sorted(list(derived_folders_from_filenames_known_txt))[0] + base_folder_names_for_post_content.append(first_match) + self.logger(f" Base folder name for post content (First match from Known.txt & Filenames): '{first_match}'") + else: + final_title_extract = extract_folder_name_from_title( + post_title, effective_unwanted_keywords_for_folder_naming + ) + base_folder_names_for_post_content.append(final_title_extract) + self.logger(f" No Known.txt match from filenames. Using title-derived name (with full ignore list): '{final_title_extract}'") + else: + extracted_name_from_title_full_ignore = extract_folder_name_from_title( + post_title, effective_unwanted_keywords_for_folder_naming + ) + base_folder_names_for_post_content.append(extracted_name_from_title_full_ignore) + self.logger(f" Base folder name(s) for post content (Generic title parsing - title not solely creator-ignored words): {', '.join(base_folder_names_for_post_content)}") + base_folder_names_for_post_content = [ + name for name in base_folder_names_for_post_content if name and name.strip() + ] + if not base_folder_names_for_post_content: + final_fallback_name = clean_folder_name(post_title if post_title and post_title.strip() else "Generic Post Content") + base_folder_names_for_post_content = [final_fallback_name] + self.logger(f" Ultimate fallback folder name: {final_fallback_name}") + + if base_folder_names_for_post_content: + determined_post_save_path_for_history = os.path.join(determined_post_save_path_for_history, base_folder_names_for_post_content[0]) + + if not self.extract_links_only and self.use_post_subfolders: + cleaned_post_title_for_sub = clean_folder_name(post_title) + post_id_for_fallback = self.post.get('id', 'unknown_id') + + if not cleaned_post_title_for_sub or cleaned_post_title_for_sub == "untitled_folder": + self.logger(f" ⚠️ Post title '{post_title}' resulted in a generic subfolder name. Using 'post_{post_id_for_fallback}' as base.") + original_cleaned_post_title_for_sub = f"post_{post_id_for_fallback}" + else: + original_cleaned_post_title_for_sub = cleaned_post_title_for_sub + + if self.use_date_prefix_for_subfolder: + published_date_str = self.post.get('published') or self.post.get('added') + if published_date_str: + try: + date_prefix = published_date_str.split('T')[0] + original_cleaned_post_title_for_sub = f"{date_prefix} {original_cleaned_post_title_for_sub}" + self.logger(f" ℹ️ Applying date prefix to subfolder: '{original_cleaned_post_title_for_sub}'") + except Exception as e: + self.logger(f" ⚠️ Could not parse date '{published_date_str}' for prefix. Using original name. Error: {e}") + else: + self.logger(" ⚠️ 'Date Prefix' is checked, but post has no 'published' or 'added' date. Omitting prefix.") + + base_path_for_post_subfolder = determined_post_save_path_for_history + suffix_counter = 0 + final_post_subfolder_name = "" + + while True: + if suffix_counter == 0: + name_candidate = original_cleaned_post_title_for_sub + else: + name_candidate = f"{original_cleaned_post_title_for_sub}_{suffix_counter}" + potential_post_subfolder_path = os.path.join(base_path_for_post_subfolder, name_candidate) + try: + os.makedirs(potential_post_subfolder_path, exist_ok=False) + final_post_subfolder_name = name_candidate + if suffix_counter > 0: + self.logger(f" Post subfolder name conflict: Using '{final_post_subfolder_name}' instead of '{original_cleaned_post_title_for_sub}' to avoid mixing posts.") + break + except FileExistsError: + suffix_counter += 1 + if suffix_counter > 100: + self.logger(f" ⚠️ Exceeded 100 attempts to find unique subfolder name for '{original_cleaned_post_title_for_sub}'. Using UUID.") + final_post_subfolder_name = f"{original_cleaned_post_title_for_sub}_{uuid.uuid4().hex[:8]}" + os.makedirs(os.path.join(base_path_for_post_subfolder, final_post_subfolder_name), exist_ok=True) + break + except OSError as e_mkdir: + self.logger(f" ❌ Error creating directory '{potential_post_subfolder_path}': {e_mkdir}. Files for this post might be saved in parent or fail.") + final_post_subfolder_name = original_cleaned_post_title_for_sub + break + determined_post_save_path_for_history = os.path.join(base_path_for_post_subfolder, final_post_subfolder_name) + + if self.filter_mode == 'text_only' and not self.extract_links_only: + self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})") + post_title_lower = post_title.lower() + if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH): + for skip_word in self.skip_words_list: + if skip_word.lower() in post_title_lower: + self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'.") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + + if current_character_filters and not post_is_candidate_by_title_char_match and not post_is_candidate_by_comment_char_match and not post_is_candidate_by_file_char_match_in_comment_scope: + self.logger(f" -> Skip Post (No character match for text extraction): '{post_title[:50]}...'.") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + + raw_text_content = "" + final_post_data = post_data + if self.text_only_scope == 'content' and 'content' not in final_post_data: + self.logger(f" Post {post_id} is missing 'content' field, fetching full data...") parsed_url = urlparse(self.api_url_input) api_domain = parsed_url.netloc - comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event) - if comments_data: - comment_texts = [] - for comment in comments_data: - user = comment.get('user', {}).get('name', 'Unknown User') - timestamp = comment.get('updated', 'No Date') - body = strip_html_tags(comment.get('content', '')) - comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n") - raw_text_content = "\n".join(comment_texts) - except Exception as e: - self.logger(f" ❌ Error fetching comments for text-only mode: {e}") + cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain) + from .api_client import fetch_single_post_data + full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies) + if full_data: + final_post_data = full_data + if self.text_only_scope == 'content': + raw_text_content = final_post_data.get('content', '') + elif self.text_only_scope == 'comments': + try: + parsed_url = urlparse(self.api_url_input) + api_domain = parsed_url.netloc + comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event) + if comments_data: + comment_texts = [] + for comment in comments_data: + user = comment.get('user', {}).get('name', 'Unknown User') + timestamp = comment.get('updated', 'No Date') + body = strip_html_tags(comment.get('content', '')) + comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n") + raw_text_content = "\n".join(comment_texts) + except Exception as e: + self.logger(f" ❌ Error fetching comments for text-only mode: {e}") - if not raw_text_content or not raw_text_content.strip(): - self.logger(" -> Skip Saving Text: No content/comments found or fetched.") - return 0, num_potential_files_in_post, [], [], [], None, None + if not raw_text_content or not raw_text_content.strip(): + self.logger(" -> Skip Saving Text: No content/comments found or fetched.") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple - # --- Robust HTML-to-TEXT Conversion --- - paragraph_pattern = re.compile(r'(.*?)

', re.IGNORECASE | re.DOTALL) - html_paragraphs = paragraph_pattern.findall(raw_text_content) - cleaned_text = "" - if not html_paragraphs: - self.logger(" ⚠️ No

tags found. Falling back to basic HTML cleaning for the whole block.") - text_with_br = re.sub(r'', '\n', raw_text_content, flags=re.IGNORECASE) - cleaned_text = re.sub(r'<.*?>', '', text_with_br) - else: - cleaned_paragraphs_list = [] - for p_content in html_paragraphs: - p_with_br = re.sub(r'', '\n', p_content, flags=re.IGNORECASE) - p_cleaned = re.sub(r'<.*?>', '', p_with_br) - p_final = html.unescape(p_cleaned).strip() - if p_final: - cleaned_paragraphs_list.append(p_final) - cleaned_text = '\n\n'.join(cleaned_paragraphs_list) - cleaned_text = cleaned_text.replace('…', '...') - - # --- Logic for Single PDF Mode (File-based) --- - if self.single_pdf_mode: - if not cleaned_text: - return 0, 0, [], [], [], None, None - - content_data = { - 'title': post_title, - 'content': cleaned_text, - 'published': self.post.get('published') or self.post.get('added') - } - temp_dir = os.path.join(self.app_base_dir, "appdata") - os.makedirs(temp_dir, exist_ok=True) - temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json" - temp_filepath = os.path.join(temp_dir, temp_filename) - - try: - with open(temp_filepath, 'w', encoding='utf-8') as f: - json.dump(content_data, f, indent=2) - self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.") - return 0, 0, [], [], [], None, temp_filepath - except Exception as e: - self.logger(f" ❌ Failed to write temporary file for single PDF: {e}") - return 0, 0, [], [], [], None, None - - # --- Logic for Individual File Saving --- - else: - file_extension = self.text_export_format - txt_filename = clean_filename(post_title) + f".{file_extension}" - final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename) - - try: - os.makedirs(determined_post_save_path_for_history, exist_ok=True) - base, ext = os.path.splitext(final_save_path) - counter = 1 - while os.path.exists(final_save_path): - final_save_path = f"{base}_{counter}{ext}" - counter += 1 - - if file_extension == 'pdf': - if FPDF: - self.logger(f" Converting to PDF...") - pdf = PDF() - font_path = "" - if self.project_root_dir: - font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') - try: - if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") - pdf.add_font('DejaVu', '', font_path, uni=True) - pdf.set_font('DejaVu', '', 12) - except Exception as font_error: - self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") - pdf.set_font('Arial', '', 12) - pdf.add_page() - pdf.multi_cell(0, 5, cleaned_text) - pdf.output(final_save_path) - else: - self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") - final_save_path = os.path.splitext(final_save_path)[0] + ".txt" - with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) - - elif file_extension == 'docx': - if Document: - self.logger(f" Converting to DOCX...") - document = Document() - document.add_paragraph(cleaned_text) - document.save(final_save_path) - else: - self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.") - final_save_path = os.path.splitext(final_save_path)[0] + ".txt" - with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) - - else: # Default to TXT - with open(final_save_path, 'w', encoding='utf-8') as f: - f.write(cleaned_text) - - self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'") - return 1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None - except Exception as e: - self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}") - return 0, num_potential_files_in_post, [], [], [], None, None - - 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 ,[],[],[],None - for folder_name_to_check in base_folder_names_for_post_content : - if not folder_name_to_check :continue - if any (skip_word .lower ()in folder_name_to_check .lower ()for skip_word in self .skip_words_list ): - matched_skip =next ((sw for sw in self .skip_words_list if sw .lower ()in folder_name_to_check .lower ()),"unknown_skip_word") - self .logger (f" -> Skip Post (Folder Keyword): Potential folder '{folder_name_to_check }' contains '{matched_skip }'.") - return 0 ,num_potential_files_in_post ,[],[],[],None, None - if (self .show_external_links or self .extract_links_only )and post_content_html : - if self ._check_pause (f"External link extraction for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None - try : - mega_key_pattern =re .compile (r'\b([a-zA-Z0-9_-]{43}|[a-zA-Z0-9_-]{22})\b') - unique_links_data ={} - for match in link_pattern .finditer (post_content_html ): - link_url =match .group (1 ).strip () - link_url =html .unescape (link_url ) - link_inner_text =match .group (2 ) - if not any (ext in link_url .lower ()for ext in ['.css','.js','.ico','.xml','.svg'])and not link_url .startswith ('javascript:')and link_url not in unique_links_data : - clean_link_text =re .sub (r'<.*?>','',link_inner_text ) - clean_link_text =html .unescape (clean_link_text ).strip () - display_text =clean_link_text if clean_link_text else "[Link]" - unique_links_data [link_url ]=display_text - links_emitted_count =0 - scraped_platforms ={'kemono','coomer','patreon'} - for link_url ,link_text in unique_links_data .items (): - platform =get_link_platform (link_url ) - decryption_key_found ="" - if platform =='mega': - parsed_mega_url =urlparse (link_url ) - if parsed_mega_url .fragment : - potential_key_from_fragment =parsed_mega_url .fragment .split ('!')[-1 ] - if mega_key_pattern .fullmatch (potential_key_from_fragment ): - decryption_key_found =potential_key_from_fragment - - if not decryption_key_found and link_text : - key_match_in_text =mega_key_pattern .search (link_text ) - if key_match_in_text : - decryption_key_found =key_match_in_text .group (1 ) - if not decryption_key_found and self .extract_links_only and post_content_html : - key_match_in_content =mega_key_pattern .search (strip_html_tags (post_content_html )) - if key_match_in_content : - decryption_key_found =key_match_in_content .group (1 ) - if platform not in scraped_platforms : - self ._emit_signal ('external_link',post_title ,link_text ,link_url ,platform ,decryption_key_found or "") - links_emitted_count +=1 - if links_emitted_count >0 :self .logger (f" 🔗 Found {links_emitted_count } potential external link(s) in post content.") - except Exception as e :self .logger (f"⚠️ Error parsing post content for links: {e }\n{traceback .format_exc (limit =2 )}") - if self .extract_links_only : - self .logger (f" Extract Links Only mode: Finished processing post {post_id } for links.") - return 0 ,0 ,[],[],[],None - all_files_from_post_api =[] - api_file_domain =urlparse (self .api_url_input ).netloc - if not api_file_domain or not any (d in api_file_domain .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']): - api_file_domain ="kemono.su"if "kemono"in self .service .lower ()else "coomer.party" - if post_main_file_info and isinstance (post_main_file_info ,dict )and post_main_file_info .get ('path'): - file_path =post_main_file_info ['path'].lstrip ('/') - original_api_name =post_main_file_info .get ('name')or os .path .basename (file_path ) - if original_api_name : - all_files_from_post_api .append ({ - 'url':f"https://{api_file_domain }{file_path }"if file_path .startswith ('/')else f"https://{api_file_domain }/data/{file_path }", - 'name':original_api_name , - '_original_name_for_log':original_api_name , - '_is_thumbnail':is_image (original_api_name ) - }) - else :self .logger (f" ⚠️ Skipping main file for post {post_id }: Missing name (Path: {file_path })") - for idx ,att_info in enumerate (post_attachments ): - if isinstance (att_info ,dict )and att_info .get ('path'): - att_path =att_info ['path'].lstrip ('/') - original_api_att_name =att_info .get ('name')or os .path .basename (att_path ) - if original_api_att_name : - all_files_from_post_api .append ({ - 'url':f"https://{api_file_domain }{att_path }"if att_path .startswith ('/')else f"https://{api_file_domain }/data/{att_path }", - 'name':original_api_att_name , - '_original_name_for_log':original_api_att_name , - '_is_thumbnail':is_image (original_api_att_name ) - }) - else :self .logger (f" ⚠️ Skipping attachment {idx +1 } for post {post_id }: Missing name (Path: {att_path })") - else :self .logger (f" ⚠️ Skipping invalid attachment {idx +1 } for post {post_id }: {str (att_info )[:100 ]}") - if self .scan_content_for_images and post_content_html and not self .extract_links_only : - self .logger (f" Scanning post content for additional image URLs (Post ID: {post_id })...") - parsed_input_url =urlparse (self .api_url_input ) - base_url_for_relative_paths =f"{parsed_input_url .scheme }://{parsed_input_url .netloc }" - img_ext_pattern ="|".join (ext .lstrip ('.')for ext in IMAGE_EXTENSIONS ) - direct_url_pattern_str =r"""(?i)\b(https?://[^\s"'<>\[\]\{\}\|\^\\^~\[\]`]+\.(?:"""+img_ext_pattern +r"""))\b""" - img_tag_src_pattern_str =r"""]*?src\s*=\s*["']([^"']+)["']""" - found_image_sources =set () - for direct_url_match in re .finditer (direct_url_pattern_str ,post_content_html ): - found_image_sources .add (direct_url_match .group (1 )) - for img_tag_match in re .finditer (img_tag_src_pattern_str ,post_content_html ,re .IGNORECASE ): - src_attr =img_tag_match .group (1 ).strip () - src_attr =html .unescape (src_attr ) - if not src_attr :continue - resolved_src_url ="" - if src_attr .startswith (('http://','https://')): - resolved_src_url =src_attr - elif src_attr .startswith ('//'): - resolved_src_url =f"{parsed_input_url .scheme }:{src_attr }" - elif src_attr .startswith ('/'): - resolved_src_url =f"{base_url_for_relative_paths }{src_attr }" - if resolved_src_url : - parsed_resolved_url =urlparse (resolved_src_url ) - if any (parsed_resolved_url .path .lower ().endswith (ext )for ext in IMAGE_EXTENSIONS ): - found_image_sources .add (resolved_src_url ) - if found_image_sources : - self .logger (f" Found {len (found_image_sources )} potential image URLs/sources in content.") - existing_urls_in_api_list ={f_info ['url']for f_info in all_files_from_post_api } - for found_url in found_image_sources : - if self .check_cancel ():break - if found_url in existing_urls_in_api_list : - self .logger (f" Skipping URL from content (already in API list or previously added from content): {found_url [:70 ]}...") - continue - try : - parsed_found_url =urlparse (found_url ) - url_filename =os .path .basename (parsed_found_url .path ) - if not url_filename or not is_image (url_filename ): - self .logger (f" Skipping URL from content (no filename part or not an image extension): {found_url [:70 ]}...") - continue - self .logger (f" Adding image from content: {url_filename } (URL: {found_url [:70 ]}...)") - all_files_from_post_api .append ({ - 'url':found_url , - 'name':url_filename , - '_original_name_for_log':url_filename , - '_is_thumbnail':False , - '_from_content_scan':True - }) - existing_urls_in_api_list .add (found_url ) - except Exception as e_url_parse : - self .logger (f" Error processing URL from content '{found_url [:70 ]}...': {e_url_parse }") - else : - self .logger (f" No additional image URLs found in post content scan for post {post_id }.") - if self .download_thumbnails : - if self .scan_content_for_images : - self .logger (f" Mode: 'Download Thumbnails Only' + 'Scan Content for Images' active. Prioritizing images from content scan for post {post_id }.") - all_files_from_post_api =[finfo for finfo in all_files_from_post_api if finfo .get ('_from_content_scan')] - if not all_files_from_post_api : - self .logger (f" -> No images found via content scan for post {post_id } in this combined mode.") - return 0 ,0 ,[],[],[],None - else : - self .logger (f" Mode: 'Download Thumbnails Only' active. Filtering for API thumbnails for post {post_id }.") - all_files_from_post_api =[finfo for finfo in all_files_from_post_api if finfo .get ('_is_thumbnail')] - if not all_files_from_post_api : - self .logger (f" -> No API image thumbnails found for post {post_id } in thumbnail-only mode.") - return 0 ,0 ,[],[],[],None - 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 () - 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 ) - self .logger (f" Manga Date Mode: Sorted {len (all_files_from_post_api )} files within post {post_id } by original name for sequential numbering.") - if not all_files_from_post_api : - self .logger (f" No files found to download for post {post_id }.") - return 0 ,0 ,[],[],[],None - files_to_download_info_list =[] - processed_original_filenames_in_this_post =set () - - if self.keep_in_post_duplicates: - # If we keep duplicates, just add every file to the list to be processed. - # The downstream hash check and rename-on-collision logic will handle them. - files_to_download_info_list.extend(all_files_from_post_api) - self.logger(f" ℹ️ 'Keep Duplicates' is on. All {len(all_files_from_post_api)} files from post will be processed.") - else: - # This is the original logic that skips duplicates by name within a post. - for file_info in all_files_from_post_api: - current_api_original_filename = file_info.get('_original_name_for_log') - if current_api_original_filename in processed_original_filenames_in_this_post: - self.logger(f" -> Skip Duplicate Original Name (within post {post_id}): '{current_api_original_filename}' already processed/listed for this post.") - total_skipped_this_post += 1 + paragraph_pattern = re.compile(r'(.*?)

', re.IGNORECASE | re.DOTALL) + html_paragraphs = paragraph_pattern.findall(raw_text_content) + cleaned_text = "" + if not html_paragraphs: + self.logger(" ⚠️ No

tags found. Falling back to basic HTML cleaning for the whole block.") + text_with_br = re.sub(r'', '\n', raw_text_content, flags=re.IGNORECASE) + cleaned_text = re.sub(r'<.*?>', '', text_with_br) else: - files_to_download_info_list.append(file_info) - if current_api_original_filename: - processed_original_filenames_in_this_post.add(current_api_original_filename) + cleaned_paragraphs_list = [] + for p_content in html_paragraphs: + p_with_br = re.sub(r'', '\n', p_content, flags=re.IGNORECASE) + p_cleaned = re.sub(r'<.*?>', '', p_with_br) + p_final = html.unescape(p_cleaned).strip() + if p_final: + cleaned_paragraphs_list.append(p_final) + cleaned_text = '\n\n'.join(cleaned_paragraphs_list) + cleaned_text = cleaned_text.replace('…', '...') - if not files_to_download_info_list: + if self.single_pdf_mode: + if not cleaned_text: + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple + content_data = { + 'title': post_title, + 'content': cleaned_text, + 'published': self.post.get('published') or self.post.get('added') + } + temp_dir = os.path.join(self.app_base_dir, "appdata") + os.makedirs(temp_dir, exist_ok=True) + temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json" + temp_filepath = os.path.join(temp_dir, temp_filename) + try: + with open(temp_filepath, 'w', encoding='utf-8') as f: + json.dump(content_data, f, indent=2) + self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.") + result_tuple = (0, 0, [], [], [], None, temp_filepath) + return result_tuple + except Exception as e: + self.logger(f" ❌ Failed to write temporary file for single PDF: {e}") + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple + else: + file_extension = self.text_export_format + txt_filename = clean_filename(post_title) + f".{file_extension}" + final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename) + try: + os.makedirs(determined_post_save_path_for_history, exist_ok=True) + base, ext = os.path.splitext(final_save_path) + counter = 1 + while os.path.exists(final_save_path): + final_save_path = f"{base}_{counter}{ext}" + counter += 1 + if file_extension == 'pdf': + if FPDF: + self.logger(f" Converting to PDF...") + pdf = PDF() + font_path = "" + if self.project_root_dir: + font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + try: + if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") + pdf.add_font('DejaVu', '', font_path, uni=True) + pdf.set_font('DejaVu', '', 12) + except Exception as font_error: + self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") + pdf.set_font('Arial', '', 12) + pdf.add_page() + pdf.multi_cell(0, 5, cleaned_text) + pdf.output(final_save_path) + else: + self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") + final_save_path = os.path.splitext(final_save_path)[0] + ".txt" + with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) + elif file_extension == 'docx': + if Document: + self.logger(f" Converting to DOCX...") + document = Document() + document.add_paragraph(cleaned_text) + document.save(final_save_path) + else: + self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.") + final_save_path = os.path.splitext(final_save_path)[0] + ".txt" + with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) + else: + with open(final_save_path, 'w', encoding='utf-8') as f: + f.write(cleaned_text) + self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'") + result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None) + return result_tuple + except Exception as e: + self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple - self .logger (f" All files for post {post_id } were duplicate original names or skipped earlier.") - return 0 ,total_skipped_this_post ,[],[],[],None + 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}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + for folder_name_to_check in base_folder_names_for_post_content: + if not folder_name_to_check: continue + if any(skip_word.lower() in folder_name_to_check.lower() for skip_word in self.skip_words_list): + matched_skip = next((sw for sw in self.skip_words_list if sw.lower() in folder_name_to_check.lower()), "unknown_skip_word") + self.logger(f" -> Skip Post (Folder Keyword): Potential folder '{folder_name_to_check}' contains '{matched_skip}'.") + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple - self .logger (f" Identified {len (files_to_download_info_list )} unique original file(s) for potential download from post {post_id }.") - with ThreadPoolExecutor (max_workers =self .num_file_threads ,thread_name_prefix =f'P{post_id }File_')as file_pool : - futures_list =[] - for file_idx ,file_info_to_dl in enumerate (files_to_download_info_list ): - if self ._check_pause (f"File processing loop for post {post_id }, file {file_idx }"):break - if self .check_cancel ():break - current_api_original_filename =file_info_to_dl .get ('_original_name_for_log') - file_is_candidate_by_char_filter_scope =False - char_filter_info_that_matched_file =None - if not current_character_filters : - file_is_candidate_by_char_filter_scope =True - else : - if self .char_filter_scope ==CHAR_SCOPE_FILES : - for filter_item_obj in current_character_filters : - terms_to_check_for_file =list (filter_item_obj ["aliases"]) - if filter_item_obj ["is_group"]and filter_item_obj ["name"]not in terms_to_check_for_file : - terms_to_check_for_file .append (filter_item_obj ["name"]) - unique_terms_for_file_check =list (set (terms_to_check_for_file )) - for term_to_match in unique_terms_for_file_check : - if is_filename_match_for_character (current_api_original_filename ,term_to_match ): - file_is_candidate_by_char_filter_scope =True - char_filter_info_that_matched_file =filter_item_obj - self .logger (f" File '{current_api_original_filename }' matches char filter term '{term_to_match }' (from '{filter_item_obj ['name']}'). Scope: Files.") - break - if file_is_candidate_by_char_filter_scope :break - elif self .char_filter_scope ==CHAR_SCOPE_TITLE : - if post_is_candidate_by_title_char_match : - file_is_candidate_by_char_filter_scope =True - 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: Title.") - elif self .char_filter_scope ==CHAR_SCOPE_BOTH : - if post_is_candidate_by_title_char_match : - file_is_candidate_by_char_filter_scope =True - 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 : - 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"]) - 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 : - if is_filename_match_for_character (current_api_original_filename ,term_to_match ): - file_is_candidate_by_char_filter_scope =True - char_filter_info_that_matched_file =filter_item_obj_both_file - self .logger (f" File '{current_api_original_filename }' matches char filter term '{term_to_match }' (from '{filter_item_obj ['name']}'). Scope: Both (File part).") - break - if file_is_candidate_by_char_filter_scope :break - elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS : - if post_is_candidate_by_file_char_match_in_comment_scope : - file_is_candidate_by_char_filter_scope =True - char_filter_info_that_matched_file =char_filter_that_matched_file_in_comment_scope - self .logger (f" File '{current_api_original_filename }' is candidate because a file in this post matched char filter (Overall Scope: Comments).") - elif post_is_candidate_by_comment_char_match : - file_is_candidate_by_char_filter_scope =True - char_filter_info_that_matched_file =char_filter_that_matched_comment - self .logger (f" File '{current_api_original_filename }' is candidate because post comments matched char filter (Overall Scope: Comments).") - if not file_is_candidate_by_char_filter_scope : - self .logger (f" -> Skip File (Char Filter Scope '{self .char_filter_scope }'): '{current_api_original_filename }' no match.") - total_skipped_this_post +=1 - continue + if (self.show_external_links or self.extract_links_only) and post_content_html: + if self._check_pause(f"External link extraction for post {post_id}"): + result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) + return result_tuple + try: + mega_key_pattern = re.compile(r'\b([a-zA-Z0-9_-]{43}|[a-zA-Z0-9_-]{22})\b') + unique_links_data = {} + for match in link_pattern.finditer(post_content_html): + link_url = match.group(1).strip() + link_url = html.unescape(link_url) + link_inner_text = match.group(2) + if not any(ext in link_url.lower() for ext in ['.css', '.js', '.ico', '.xml', '.svg']) and not link_url.startswith('javascript:') and link_url not in unique_links_data: + clean_link_text = re.sub(r'<.*?>', '', link_inner_text) + clean_link_text = html.unescape(clean_link_text).strip() + display_text = clean_link_text if clean_link_text else "[Link]" + unique_links_data[link_url] = display_text + links_emitted_count = 0 + scraped_platforms = {'kemono', 'coomer', 'patreon'} + for link_url, link_text in unique_links_data.items(): + platform = get_link_platform(link_url) + decryption_key_found = "" + if platform == 'mega': + parsed_mega_url = urlparse(link_url) + if parsed_mega_url.fragment: + potential_key_from_fragment = parsed_mega_url.fragment.split('!')[-1] + if mega_key_pattern.fullmatch(potential_key_from_fragment): + decryption_key_found = potential_key_from_fragment + if not decryption_key_found and link_text: + key_match_in_text = mega_key_pattern.search(link_text) + if key_match_in_text: + decryption_key_found = key_match_in_text.group(1) + if not decryption_key_found and self.extract_links_only and post_content_html: + key_match_in_content = mega_key_pattern.search(strip_html_tags(post_content_html)) + if key_match_in_content: + decryption_key_found = key_match_in_content.group(1) + if platform not in scraped_platforms: + self._emit_signal('external_link', post_title, link_text, link_url, platform, decryption_key_found or "") + links_emitted_count += 1 + if links_emitted_count > 0: self.logger(f" 🔗 Found {links_emitted_count} potential external link(s) in post content.") + except Exception as e: + self.logger(f"⚠️ Error parsing post content for links: {e}\n{traceback.format_exc(limit=2)}") + if self.extract_links_only: + self.logger(f" Extract Links Only mode: Finished processing post {post_id} for links.") + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple - target_base_folders_for_this_file_iteration =[] + all_files_from_post_api = [] + api_file_domain = urlparse(self.api_url_input).netloc + if not api_file_domain or not any(d in api_file_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']): + api_file_domain = "kemono.su" if "kemono" in self.service.lower() else "coomer.party" - if current_character_filters : - char_title_subfolder_name =None - if self .target_post_id_from_initial_url and self .custom_folder_name : - char_title_subfolder_name =self .custom_folder_name - elif char_filter_info_that_matched_file : - char_title_subfolder_name =clean_folder_name (char_filter_info_that_matched_file ["name"]) - elif char_filter_that_matched_title : - char_title_subfolder_name =clean_folder_name (char_filter_that_matched_title ["name"]) - elif char_filter_that_matched_comment : - char_title_subfolder_name =clean_folder_name (char_filter_that_matched_comment ["name"]) - if char_title_subfolder_name : - target_base_folders_for_this_file_iteration .append (char_title_subfolder_name ) - else : - self .logger (f"⚠️ File '{current_api_original_filename }' candidate by char filter, but no folder name derived. Using post title.") - target_base_folders_for_this_file_iteration .append (clean_folder_name (post_title )) - else : - if base_folder_names_for_post_content : - target_base_folders_for_this_file_iteration .extend (base_folder_names_for_post_content ) - else : - target_base_folders_for_this_file_iteration .append (clean_folder_name (post_title )) + if post_main_file_info and isinstance(post_main_file_info, dict) and post_main_file_info.get('path'): + file_path = post_main_file_info['path'].lstrip('/') + original_api_name = post_main_file_info.get('name') or os.path.basename(file_path) + if original_api_name: + all_files_from_post_api.append({ + 'url': f"https://{api_file_domain}{file_path}" if file_path.startswith('/') else f"https://{api_file_domain}/data/{file_path}", + 'name': original_api_name, + '_original_name_for_log': original_api_name, + '_is_thumbnail': is_image(original_api_name) + }) + else: + self.logger(f" ⚠️ Skipping main file for post {post_id}: Missing name (Path: {file_path})") - if not target_base_folders_for_this_file_iteration : - target_base_folders_for_this_file_iteration .append (clean_folder_name (post_title if post_title else "Uncategorized_Post_Content")) + for idx, att_info in enumerate(post_attachments): + if isinstance(att_info, dict) and att_info.get('path'): + att_path = att_info['path'].lstrip('/') + original_api_att_name = att_info.get('name') or os.path.basename(att_path) + if original_api_att_name: + all_files_from_post_api.append({ + 'url': f"https://{api_file_domain}{att_path}" if att_path.startswith('/') else f"https://{api_file_domain}/data/{att_path}", + 'name': original_api_att_name, + '_original_name_for_log': original_api_att_name, + '_is_thumbnail': is_image(original_api_att_name) + }) + else: + self.logger(f" ⚠️ Skipping attachment {idx + 1} for post {post_id}: Missing name (Path: {att_path})") + else: + self.logger(f" ⚠️ Skipping invalid attachment {idx + 1} for post {post_id}: {str(att_info)[:100]}") - for target_base_folder_name_for_instance in target_base_folders_for_this_file_iteration : - current_path_for_file_instance =self .override_output_dir if self .override_output_dir else self .download_root - if self .use_subfolders and target_base_folder_name_for_instance : - current_path_for_file_instance =os .path .join (current_path_for_file_instance ,target_base_folder_name_for_instance ) - if self .use_post_subfolders : + if self.scan_content_for_images and post_content_html and not self.extract_links_only: + self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...") + parsed_input_url = urlparse(self.api_url_input) + base_url_for_relative_paths = f"{parsed_input_url.scheme}://{parsed_input_url.netloc}" + img_ext_pattern = "|".join(ext.lstrip('.') for ext in IMAGE_EXTENSIONS) + direct_url_pattern_str = r"""(?i)\b(https?://[^\s"'<>\[\]\{\}\|\^\\^~\[\]`]+\.(?:""" + img_ext_pattern + r"""))\b""" + img_tag_src_pattern_str = r"""]*?src\s*=\s*["']([^"']+)["']""" + found_image_sources = set() + for direct_url_match in re.finditer(direct_url_pattern_str, post_content_html): + found_image_sources.add(direct_url_match.group(1)) + for img_tag_match in re.finditer(img_tag_src_pattern_str, post_content_html, re.IGNORECASE): + src_attr = img_tag_match.group(1).strip() + src_attr = html.unescape(src_attr) + if not src_attr: continue + resolved_src_url = "" + if src_attr.startswith(('http://', 'https://')): + resolved_src_url = src_attr + elif src_attr.startswith('//'): + resolved_src_url = f"{parsed_input_url.scheme}:{src_attr}" + elif src_attr.startswith('/'): + resolved_src_url = f"{base_url_for_relative_paths}{src_attr}" + if resolved_src_url: + parsed_resolved_url = urlparse(resolved_src_url) + if any(parsed_resolved_url.path.lower().endswith(ext) for ext in IMAGE_EXTENSIONS): + found_image_sources.add(resolved_src_url) + if found_image_sources: + self.logger(f" Found {len(found_image_sources)} potential image URLs/sources in content.") + existing_urls_in_api_list = {f_info['url'] for f_info in all_files_from_post_api} + for found_url in found_image_sources: + if self.check_cancel(): break + if found_url in existing_urls_in_api_list: + self.logger(f" Skipping URL from content (already in API list or previously added from content): {found_url[:70]}...") + continue + try: + parsed_found_url = urlparse(found_url) + url_filename = os.path.basename(parsed_found_url.path) + if not url_filename or not is_image(url_filename): + self.logger(f" Skipping URL from content (no filename part or not an image extension): {found_url[:70]}...") + continue + self.logger(f" Adding image from content: {url_filename} (URL: {found_url[:70]}...)") + all_files_from_post_api.append({ + 'url': found_url, + 'name': url_filename, + '_original_name_for_log': url_filename, + '_is_thumbnail': False, + '_from_content_scan': True + }) + existing_urls_in_api_list.add(found_url) + except Exception as e_url_parse: + self.logger(f" Error processing URL from content '{found_url[:70]}...': {e_url_parse}") + else: + self.logger(f" No additional image URLs found in post content scan for post {post_id}.") - current_path_for_file_instance =os .path .join (current_path_for_file_instance ,final_post_subfolder_name ) + if self.download_thumbnails: + if self.scan_content_for_images: + self.logger(f" Mode: 'Download Thumbnails Only' + 'Scan Content for Images' active. Prioritizing images from content scan for post {post_id}.") + all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo.get('_from_content_scan')] + if not all_files_from_post_api: + self.logger(f" -> No images found via content scan for post {post_id} in this combined mode.") + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple + else: + self.logger(f" Mode: 'Download Thumbnails Only' active. Filtering for API thumbnails for post {post_id}.") + all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo.get('_is_thumbnail')] + if not all_files_from_post_api: + self.logger(f" -> No API image thumbnails found for post {post_id} in thumbnail-only mode.") + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple - manga_date_counter_to_pass =self .manga_date_file_counter_ref if self .manga_mode_active and self .manga_filename_style ==STYLE_DATE_BASED else None - manga_global_counter_to_pass =self .manga_global_file_counter_ref if self .manga_mode_active and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING else None + 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() + 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) + self.logger(f" Manga Date Mode: Sorted {len(all_files_from_post_api)} files within post {post_id} by original name for sequential numbering.") + if not all_files_from_post_api: + self.logger(f" No files found to download for post {post_id}.") + result_tuple = (0, 0, [], [], [], None, None) + return result_tuple - folder_context_for_file =target_base_folder_name_for_instance if self .use_subfolders and target_base_folder_name_for_instance else clean_folder_name (post_title ) + files_to_download_info_list = [] + processed_original_filenames_in_this_post = set() + if self.keep_in_post_duplicates: + files_to_download_info_list.extend(all_files_from_post_api) + self.logger(f" ℹ️ 'Keep Duplicates' is on. All {len(all_files_from_post_api)} files from post will be processed.") + else: + for file_info in all_files_from_post_api: + current_api_original_filename = file_info.get('_original_name_for_log') + if current_api_original_filename in processed_original_filenames_in_this_post: + self.logger(f" -> Skip Duplicate Original Name (within post {post_id}): '{current_api_original_filename}' already processed/listed for this post.") + total_skipped_this_post += 1 + else: + files_to_download_info_list.append(file_info) + if current_api_original_filename: + processed_original_filenames_in_this_post.add(current_api_original_filename) - futures_list .append (file_pool .submit ( - self ._download_single_file , - file_info =file_info_to_dl , - target_folder_path =current_path_for_file_instance , - headers =headers ,original_post_id_for_log =post_id ,skip_event =self .skip_current_file_flag , - post_title =post_title ,manga_date_file_counter_ref =manga_date_counter_to_pass , - manga_global_file_counter_ref =manga_global_counter_to_pass ,folder_context_name_for_history =folder_context_for_file , - file_index_in_post =file_idx ,num_files_in_this_post =len (files_to_download_info_list ) - )) + if not files_to_download_info_list: + self.logger(f" All files for post {post_id} were duplicate original names or skipped earlier.") + result_tuple = (0, total_skipped_this_post, [], [], [], None, None) + return result_tuple - for future in as_completed (futures_list ): - if self .check_cancel (): - for f_to_cancel in futures_list : - if not f_to_cancel .done (): - f_to_cancel .cancel () - break - try : - dl_count ,skip_count ,actual_filename_saved ,original_kept_flag ,status ,details_for_dialog_or_retry =future .result () - total_downloaded_this_post +=dl_count - total_skipped_this_post +=skip_count - if original_kept_flag and dl_count >0 and actual_filename_saved : - kept_original_filenames_for_log .append (actual_filename_saved ) - if status ==FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER and details_for_dialog_or_retry : - retryable_failures_this_post .append (details_for_dialog_or_retry ) - elif status ==FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION and details_for_dialog_or_retry : - permanent_failures_this_post .append (details_for_dialog_or_retry ) - except CancelledError : - self .logger (f" File download task for post {post_id } was cancelled.") - total_skipped_this_post +=1 - 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 - self ._emit_signal ('file_progress',"",None ) + self.logger(f" Identified {len(files_to_download_info_list)} unique original file(s) for potential download from post {post_id}.") + with ThreadPoolExecutor(max_workers=self.num_file_threads, thread_name_prefix=f'P{post_id}File_') as file_pool: + futures_list = [] + for file_idx, file_info_to_dl in enumerate(files_to_download_info_list): + if self._check_pause(f"File processing loop for post {post_id}, file {file_idx}"): break + if self.check_cancel(): break + current_api_original_filename = file_info_to_dl.get('_original_name_for_log') + file_is_candidate_by_char_filter_scope = False + char_filter_info_that_matched_file = None + if not current_character_filters: + file_is_candidate_by_char_filter_scope = True + else: + if self.char_filter_scope == CHAR_SCOPE_FILES: + for filter_item_obj in current_character_filters: + terms_to_check_for_file = list(filter_item_obj["aliases"]) + if filter_item_obj["is_group"] and filter_item_obj["name"] not in terms_to_check_for_file: + terms_to_check_for_file.append(filter_item_obj["name"]) + unique_terms_for_file_check = list(set(terms_to_check_for_file)) + for term_to_match in unique_terms_for_file_check: + if is_filename_match_for_character(current_api_original_filename, term_to_match): + file_is_candidate_by_char_filter_scope = True + char_filter_info_that_matched_file = filter_item_obj + self.logger(f" File '{current_api_original_filename}' matches char filter term '{term_to_match}' (from '{filter_item_obj['name']}'). Scope: Files.") + break + if file_is_candidate_by_char_filter_scope: break + elif self.char_filter_scope == CHAR_SCOPE_TITLE: + if post_is_candidate_by_title_char_match: + file_is_candidate_by_char_filter_scope = True + 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: Title.") + elif self.char_filter_scope == CHAR_SCOPE_BOTH: + if post_is_candidate_by_title_char_match: + file_is_candidate_by_char_filter_scope = True + 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: + 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"]) + 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: + if is_filename_match_for_character(current_api_original_filename, term_to_match): + file_is_candidate_by_char_filter_scope = True + char_filter_info_that_matched_file = filter_item_obj_both_file + self.logger(f" File '{current_api_original_filename}' matches char filter term '{term_to_match}' (from '{filter_item_obj['name']}'). Scope: Both (File part).") + break + if file_is_candidate_by_char_filter_scope: break + elif self.char_filter_scope == CHAR_SCOPE_COMMENTS: + if post_is_candidate_by_file_char_match_in_comment_scope: + file_is_candidate_by_char_filter_scope = True + char_filter_info_that_matched_file = char_filter_that_matched_file_in_comment_scope + self.logger(f" File '{current_api_original_filename}' is candidate because a file in this post matched char filter (Overall Scope: Comments).") + elif post_is_candidate_by_comment_char_match: + file_is_candidate_by_char_filter_scope = True + char_filter_info_that_matched_file = char_filter_that_matched_comment + self.logger(f" File '{current_api_original_filename}' is candidate because post comments matched char filter (Overall Scope: Comments).") + if not file_is_candidate_by_char_filter_scope: + self.logger(f" -> Skip File (Char Filter Scope '{self.char_filter_scope}'): '{current_api_original_filename}' no match.") + total_skipped_this_post += 1 + continue - # After a post's files are all processed, update the session file to mark this post as done. - if self.session_file_path and self.session_lock: - try: - with self.session_lock: - if os.path.exists(self.session_file_path): # Only update if the session file exists - # Read current state - with open(self.session_file_path, 'r', encoding='utf-8') as f: - session_data = json.load(f) + target_base_folders_for_this_file_iteration = [] + if current_character_filters: + char_title_subfolder_name = None + if self.target_post_id_from_initial_url and self.custom_folder_name: + char_title_subfolder_name = self.custom_folder_name + elif char_filter_info_that_matched_file: + char_title_subfolder_name = clean_folder_name(char_filter_info_that_matched_file["name"]) + elif char_filter_that_matched_title: + char_title_subfolder_name = clean_folder_name(char_filter_that_matched_title["name"]) + elif char_filter_that_matched_comment: + char_title_subfolder_name = clean_folder_name(char_filter_that_matched_comment["name"]) + if char_title_subfolder_name: + target_base_folders_for_this_file_iteration.append(char_title_subfolder_name) + else: + self.logger(f"⚠️ File '{current_api_original_filename}' candidate by char filter, but no folder name derived. Using post title.") + target_base_folders_for_this_file_iteration.append(clean_folder_name(post_title)) + else: + if base_folder_names_for_post_content: + target_base_folders_for_this_file_iteration.extend(base_folder_names_for_post_content) + else: + target_base_folders_for_this_file_iteration.append(clean_folder_name(post_title)) + + if not target_base_folders_for_this_file_iteration: + target_base_folders_for_this_file_iteration.append(clean_folder_name(post_title if post_title else "Uncategorized_Post_Content")) + + for target_base_folder_name_for_instance in target_base_folders_for_this_file_iteration: + current_path_for_file_instance = self.override_output_dir if self.override_output_dir else self.download_root + if self.use_subfolders and target_base_folder_name_for_instance: + current_path_for_file_instance = os.path.join(current_path_for_file_instance, target_base_folder_name_for_instance) + if self.use_post_subfolders: + current_path_for_file_instance = os.path.join(current_path_for_file_instance, final_post_subfolder_name) + + manga_date_counter_to_pass = self.manga_date_file_counter_ref if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED else None + manga_global_counter_to_pass = self.manga_global_file_counter_ref if self.manga_mode_active and self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING else None + folder_context_for_file = target_base_folder_name_for_instance if self.use_subfolders and target_base_folder_name_for_instance else clean_folder_name(post_title) + + futures_list.append(file_pool.submit( + self._download_single_file, + file_info=file_info_to_dl, + target_folder_path=current_path_for_file_instance, + headers=headers, original_post_id_for_log=post_id, skip_event=self.skip_current_file_flag, + post_title=post_title, manga_date_file_counter_ref=manga_date_counter_to_pass, + manga_global_file_counter_ref=manga_global_counter_to_pass, folder_context_name_for_history=folder_context_for_file, + file_index_in_post=file_idx, num_files_in_this_post=len(files_to_download_info_list) + )) + + for future in as_completed(futures_list): + if self.check_cancel(): + for f_to_cancel in futures_list: + if not f_to_cancel.done(): + f_to_cancel.cancel() + break + try: + dl_count, skip_count, actual_filename_saved, original_kept_flag, status, details_for_dialog_or_retry = future.result() + total_downloaded_this_post += dl_count + total_skipped_this_post += skip_count + if original_kept_flag and dl_count > 0 and actual_filename_saved: + kept_original_filenames_for_log.append(actual_filename_saved) + if status == FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER and details_for_dialog_or_retry: + retryable_failures_this_post.append(details_for_dialog_or_retry) + elif status == FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION and details_for_dialog_or_retry: + permanent_failures_this_post.append(details_for_dialog_or_retry) + except CancelledError: + self.logger(f" File download task for post {post_id} was cancelled.") + total_skipped_this_post += 1 + 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 + self._emit_signal('file_progress', "", None) + + if self.session_file_path and self.session_lock: + try: + with self.session_lock: + if os.path.exists(self.session_file_path): + with open(self.session_file_path, 'r', encoding='utf-8') as f: + session_data = json.load(f) - if 'download_state' not in session_data: - session_data['download_state'] = {} + if 'download_state' not in session_data: + session_data['download_state'] = {} + if not isinstance(session_data['download_state'].get('processed_post_ids'), list): + session_data['download_state']['processed_post_ids'] = [] + + session_data['download_state']['processed_post_ids'].append(self.post.get('id')) - # Add processed ID - if not isinstance(session_data['download_state'].get('processed_post_ids'), list): - session_data['download_state']['processed_post_ids'] = [] - session_data['download_state']['processed_post_ids'].append(self.post.get('id')) + if 'manga_counters' not in session_data['download_state']: + session_data['download_state']['manga_counters'] = {} + + if self.manga_date_file_counter_ref is not None: + session_data['download_state']['manga_counters']['date_based'] = self.manga_date_file_counter_ref[0] + + if self.manga_global_file_counter_ref is not None: + session_data['download_state']['manga_counters']['global_numbering'] = self.manga_global_file_counter_ref[0] + + if permanent_failures_this_post: + if not isinstance(session_data['download_state'].get('permanently_failed_files'), list): + session_data['download_state']['permanently_failed_files'] = [] + existing_failed_urls = {f.get('file_info', {}).get('url') for f in session_data['download_state']['permanently_failed_files']} + for failure in permanent_failures_this_post: + if failure.get('file_info', {}).get('url') not in existing_failed_urls: + session_data['download_state']['permanently_failed_files'].append(failure) + temp_file_path = self.session_file_path + ".tmp" + with open(temp_file_path, 'w', encoding='utf-8') as f_tmp: + json.dump(session_data, f_tmp, indent=2) + os.replace(temp_file_path, self.session_file_path) + except Exception as e: + self.logger(f"⚠️ Could not update session file for post {post_id}: {e}") - # Add any permanent failures from this worker to the session file - if permanent_failures_this_post: - if not isinstance(session_data['download_state'].get('permanently_failed_files'), list): - session_data['download_state']['permanently_failed_files'] = [] - # To avoid duplicates if the same post is somehow re-processed - existing_failed_urls = {f.get('file_info', {}).get('url') for f in session_data['download_state']['permanently_failed_files']} - for failure in permanent_failures_this_post: - if failure.get('file_info', {}).get('url') not in existing_failed_urls: - session_data['download_state']['permanently_failed_files'].append(failure) + if not self.extract_links_only and (total_downloaded_this_post > 0 or not ( + (current_character_filters and ( + (self.char_filter_scope == CHAR_SCOPE_TITLE and not post_is_candidate_by_title_char_match) or + (self.char_filter_scope == CHAR_SCOPE_COMMENTS and not post_is_candidate_by_file_char_match_in_comment_scope and not post_is_candidate_by_comment_char_match) + )) or + (self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH) and any(sw.lower() in post_title.lower() for sw in self.skip_words_list)) + )): + top_file_name_for_history = "N/A" + if post_main_file_info and post_main_file_info.get('name'): + top_file_name_for_history = post_main_file_info['name'] + elif post_attachments and post_attachments[0].get('name'): + top_file_name_for_history = post_attachments[0]['name'] + history_data_for_this_post = { + 'post_title': post_title, 'post_id': post_id, + 'top_file_name': top_file_name_for_history, + 'num_files': num_potential_files_in_post, + 'upload_date_str': post_data.get('published') or post_data.get('added') or "Unknown", + 'download_location': determined_post_save_path_for_history, + 'service': self.service, 'user_id': self.user_id, + } - # Write to temp file and then atomically replace - temp_file_path = self.session_file_path + ".tmp" - with open(temp_file_path, 'w', encoding='utf-8') as f_tmp: - json.dump(session_data, f_tmp, indent=2) - os.replace(temp_file_path, self.session_file_path) - except Exception as e: - self.logger(f"⚠️ Could not update session file for post {post_id}: {e}") + if self.check_cancel(): + self.logger(f" Post {post_id} processing interrupted/cancelled.") + else: + self.logger(f" Post {post_id} Summary: Downloaded={total_downloaded_this_post}, Skipped Files={total_skipped_this_post}") - if not self .extract_links_only and (total_downloaded_this_post >0 or not ( - (current_character_filters and ( - (self .char_filter_scope ==CHAR_SCOPE_TITLE and not post_is_candidate_by_title_char_match )or - (self .char_filter_scope ==CHAR_SCOPE_COMMENTS and not post_is_candidate_by_file_char_match_in_comment_scope and not post_is_candidate_by_comment_char_match ) - ))or - (self .skip_words_list and (self .skip_words_scope ==SKIP_SCOPE_POSTS or self .skip_words_scope ==SKIP_SCOPE_BOTH )and any (sw .lower ()in post_title .lower ()for sw in self .skip_words_list )) - )): - top_file_name_for_history ="N/A" - if post_main_file_info and post_main_file_info .get ('name'): - top_file_name_for_history =post_main_file_info ['name'] - elif post_attachments and post_attachments [0 ].get ('name'): - top_file_name_for_history =post_attachments [0 ]['name'] + if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0: + path_to_check_for_emptiness = determined_post_save_path_for_history + try: + if os.path.isdir(path_to_check_for_emptiness) and not os.listdir(path_to_check_for_emptiness): + self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'") + os.rmdir(path_to_check_for_emptiness) + except OSError as e_rmdir: + self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}") - history_data_for_this_post ={ - 'post_title':post_title ,'post_id':post_id , - 'top_file_name':top_file_name_for_history , - 'num_files':num_potential_files_in_post , - 'upload_date_str':post_data .get ('published')or post_data .get ('added')or "Unknown", - 'download_location':determined_post_save_path_for_history , - 'service':self .service ,'user_id':self .user_id , - } - if self .check_cancel ():self .logger (f" Post {post_id } processing interrupted/cancelled."); - else :self .logger (f" Post {post_id } Summary: Downloaded={total_downloaded_this_post }, Skipped Files={total_skipped_this_post }") + # After all processing, set the final result tuple for the normal execution path + result_tuple = (total_downloaded_this_post, total_skipped_this_post, + kept_original_filenames_for_log, retryable_failures_this_post, + permanent_failures_this_post, history_data_for_this_post, + None) - if not self .extract_links_only and self .use_post_subfolders and total_downloaded_this_post ==0 : + finally: + # This block is GUARANTEED to execute, sending the signal for multi-threaded mode. + self._emit_signal('worker_finished', result_tuple) - path_to_check_for_emptiness =determined_post_save_path_for_history - try : - if os .path .isdir (path_to_check_for_emptiness )and not os .listdir (path_to_check_for_emptiness ): - self .logger (f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness }'") - os .rmdir (path_to_check_for_emptiness ) - except OSError as e_rmdir : - self .logger (f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness }': {e_rmdir }") + # This line is the critical fix. It ensures the method always returns a tuple + # for the single-threaded mode that directly calls it. + return result_tuple - result_tuple = (total_downloaded_this_post, total_skipped_this_post, - kept_original_filenames_for_log, retryable_failures_this_post, - permanent_failures_this_post, history_data_for_this_post, - None) # The 7th item is None because we already saved the temp file +class DownloadThread(QThread): + progress_signal = pyqtSignal(str) + add_character_prompt_signal = pyqtSignal(str) + file_download_status_signal = pyqtSignal(bool) + finished_signal = pyqtSignal(int, int, bool, list) + external_link_signal = pyqtSignal(str, str, str, str, str) + file_successfully_downloaded_signal = pyqtSignal(dict) + file_progress_signal = pyqtSignal(str, object) + retryable_file_failed_signal = pyqtSignal(list) + missed_character_post_signal = pyqtSignal(str, str) + post_processed_for_history_signal = pyqtSignal(dict) + final_history_entries_signal = pyqtSignal(list) + permanent_file_failed_signal = pyqtSignal(list) - # In Single PDF mode, the 7th item is the temp file path we created. - if self.single_pdf_mode and os.path.exists(temp_filepath): - result_tuple = (0, 0, [], [], [], None, temp_filepath) - - self._emit_signal('worker_finished', result_tuple) - return # The method now returns nothing. - -class DownloadThread (QThread ): - progress_signal =pyqtSignal (str ) - add_character_prompt_signal =pyqtSignal (str ) - file_download_status_signal =pyqtSignal (bool ) - finished_signal =pyqtSignal (int ,int ,bool ,list ) - external_link_signal =pyqtSignal (str ,str ,str ,str ,str ) - file_successfully_downloaded_signal =pyqtSignal (dict ) - file_progress_signal =pyqtSignal (str ,object ) - retryable_file_failed_signal =pyqtSignal (list ) - missed_character_post_signal =pyqtSignal (str ,str ) - post_processed_for_history_signal =pyqtSignal (dict ) - final_history_entries_signal =pyqtSignal (list ) - permanent_file_failed_signal =pyqtSignal (list ) - def __init__ (self ,api_url_input ,output_dir ,known_names_copy , - cancellation_event , - pause_event ,filter_character_list =None ,dynamic_character_filter_holder =None , - filter_mode ='all',skip_zip =True ,skip_rar =True , - use_subfolders =True ,use_post_subfolders =False ,custom_folder_name =None ,compress_images =False , - download_thumbnails =False ,service =None ,user_id =None , - downloaded_files =None ,downloaded_file_hashes =None ,downloaded_files_lock =None ,downloaded_file_hashes_lock =None , - skip_words_list =None , - skip_words_scope =SKIP_SCOPE_FILES , - show_external_links =False , - extract_links_only =False , - num_file_threads_for_worker =1 , - skip_current_file_flag =None , - start_page =None ,end_page =None , - target_post_id_from_initial_url =None , - manga_mode_active =False , - unwanted_keywords =None , - manga_filename_style =STYLE_POST_TITLE , - char_filter_scope =CHAR_SCOPE_FILES , - remove_from_filename_words_list =None , - manga_date_prefix =MANGA_DATE_PREFIX_DEFAULT , - allow_multipart_download =True , - selected_cookie_file =None , - override_output_dir =None , - app_base_dir =None , - manga_date_file_counter_ref =None , - manga_global_file_counter_ref =None , - use_cookie =False , - scan_content_for_images =False , - creator_download_folder_ignore_words =None , - use_date_prefix_for_subfolder=False, - keep_in_post_duplicates=False, - cookie_text ="", - session_file_path=None, - session_lock=None, - text_only_scope=None, - text_export_format='txt', - single_pdf_mode=False, - project_root_dir=None, - ): - super ().__init__ () - self .api_url_input =api_url_input - self .output_dir =output_dir - self .known_names =list (known_names_copy ) - self .cancellation_event =cancellation_event - self .pause_event =pause_event - self .skip_current_file_flag =skip_current_file_flag - self .initial_target_post_id =target_post_id_from_initial_url - self .filter_character_list_objects_initial =filter_character_list if filter_character_list else [] - self .dynamic_filter_holder =dynamic_character_filter_holder - self .filter_mode =filter_mode - self .skip_zip =skip_zip - self .skip_rar =skip_rar - self .use_subfolders =use_subfolders - self .use_post_subfolders =use_post_subfolders - self .custom_folder_name =custom_folder_name - self .compress_images =compress_images - self .download_thumbnails =download_thumbnails - self .service =service - self .user_id =user_id - self .skip_words_list =skip_words_list if skip_words_list is not None else [] - self .skip_words_scope =skip_words_scope - self .downloaded_files =downloaded_files - self .downloaded_files_lock =downloaded_files_lock - self .downloaded_file_hashes =downloaded_file_hashes - self .downloaded_file_hashes_lock =downloaded_file_hashes_lock - self ._add_character_response =None - self .prompt_mutex =QMutex () - self .show_external_links =show_external_links - self .extract_links_only =extract_links_only - self .num_file_threads_for_worker =num_file_threads_for_worker - self .start_page =start_page - self .end_page =end_page - self .manga_mode_active =manga_mode_active - self .unwanted_keywords =unwanted_keywords if unwanted_keywords is not None else {'spicy','hd','nsfw','4k','preview','teaser','clip'} - self .manga_filename_style =manga_filename_style - self .char_filter_scope =char_filter_scope - self .remove_from_filename_words_list =remove_from_filename_words_list - self .manga_date_prefix =manga_date_prefix - self .allow_multipart_download =allow_multipart_download - self .selected_cookie_file =selected_cookie_file - self .app_base_dir =app_base_dir - self .cookie_text =cookie_text - self .use_cookie =use_cookie - self .override_output_dir =override_output_dir - self .manga_date_file_counter_ref =manga_date_file_counter_ref - self .scan_content_for_images =scan_content_for_images - self .creator_download_folder_ignore_words =creator_download_folder_ignore_words + def __init__(self, api_url_input, output_dir, known_names_copy, + cancellation_event, + pause_event, filter_character_list=None, dynamic_character_filter_holder=None, + filter_mode='all', skip_zip=True, skip_rar=True, + use_subfolders=True, use_post_subfolders=False, custom_folder_name=None, compress_images=False, + download_thumbnails=False, service=None, user_id=None, + downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None, + skip_words_list=None, + skip_words_scope=SKIP_SCOPE_FILES, + show_external_links=False, + extract_links_only=False, + num_file_threads_for_worker=1, + skip_current_file_flag=None, + start_page=None, end_page=None, + target_post_id_from_initial_url=None, + manga_mode_active=False, + unwanted_keywords=None, + manga_filename_style=STYLE_POST_TITLE, + char_filter_scope=CHAR_SCOPE_FILES, + remove_from_filename_words_list=None, + manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, + allow_multipart_download=True, + selected_cookie_file=None, + override_output_dir=None, + app_base_dir=None, + manga_date_file_counter_ref=None, + manga_global_file_counter_ref=None, + use_cookie=False, + scan_content_for_images=False, + creator_download_folder_ignore_words=None, + use_date_prefix_for_subfolder=False, + keep_in_post_duplicates=False, + cookie_text="", + session_file_path=None, + session_lock=None, + text_only_scope=None, + text_export_format='txt', + single_pdf_mode=False, + project_root_dir=None, + processed_post_ids=None): # Add processed_post_ids here + super().__init__() + self.api_url_input = api_url_input + self.output_dir = output_dir + self.known_names = list(known_names_copy) + self.cancellation_event = cancellation_event + self.pause_event = pause_event + self.skip_current_file_flag = skip_current_file_flag + self.initial_target_post_id = target_post_id_from_initial_url + self.filter_character_list_objects_initial = filter_character_list if filter_character_list else [] + self.dynamic_filter_holder = dynamic_character_filter_holder + self.filter_mode = filter_mode + self.skip_zip = skip_zip + self.skip_rar = skip_rar + self.use_subfolders = use_subfolders + self.use_post_subfolders = use_post_subfolders + self.custom_folder_name = custom_folder_name + self.compress_images = compress_images + self.download_thumbnails = download_thumbnails + self.service = service + self.user_id = user_id + self.skip_words_list = skip_words_list if skip_words_list is not None else [] + self.skip_words_scope = skip_words_scope + self.downloaded_files = downloaded_files + self.downloaded_files_lock = downloaded_files_lock + self.downloaded_file_hashes = downloaded_file_hashes + self.downloaded_file_hashes_lock = downloaded_file_hashes_lock + self._add_character_response = None + self.prompt_mutex = QMutex() + self.show_external_links = show_external_links + self.extract_links_only = extract_links_only + self.num_file_threads_for_worker = num_file_threads_for_worker + self.start_page = start_page + self.end_page = end_page + self.manga_mode_active = manga_mode_active + self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else {'spicy', 'hd', 'nsfw', '4k', 'preview', 'teaser', 'clip'} + self.manga_filename_style = manga_filename_style + self.char_filter_scope = char_filter_scope + self.remove_from_filename_words_list = remove_from_filename_words_list + self.manga_date_prefix = manga_date_prefix + self.allow_multipart_download = allow_multipart_download + self.selected_cookie_file = selected_cookie_file + self.app_base_dir = app_base_dir + self.cookie_text = cookie_text + self.use_cookie = use_cookie + self.override_output_dir = override_output_dir + self.manga_date_file_counter_ref = manga_date_file_counter_ref + self.scan_content_for_images = scan_content_for_images + self.creator_download_folder_ignore_words = creator_download_folder_ignore_words self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder self.keep_in_post_duplicates = keep_in_post_duplicates - self .manga_global_file_counter_ref =manga_global_file_counter_ref + self.manga_global_file_counter_ref = manga_global_file_counter_ref self.session_file_path = session_file_path self.session_lock = session_lock - self.history_candidates_buffer =deque (maxlen =8 ) + self.history_candidates_buffer = deque(maxlen=8) self.text_only_scope = text_only_scope self.text_export_format = text_export_format - self.single_pdf_mode = single_pdf_mode # <-- ADD THIS LINE - self.project_root_dir = project_root_dir # Add this assignment + self.single_pdf_mode = single_pdf_mode + self.project_root_dir = project_root_dir + self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] # Add this line - if self .compress_images and Image is None : - self .logger ("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") - self .compress_images =False - def logger (self ,message ): - self .progress_signal .emit (str (message )) - def isInterruptionRequested (self ): - return self .cancellation_event .is_set ()or super ().isInterruptionRequested () - def _check_pause_self (self ,context_message ="DownloadThread operation"): - if self .pause_event and self .pause_event .is_set (): - self .logger (f" {context_message } paused...") - while self .pause_event .is_set (): - if self .isInterruptionRequested (): - self .logger (f" {context_message } cancelled while paused.") - return True - time .sleep (0.5 ) - if not self .isInterruptionRequested ():self .logger (f" {context_message } resumed.") - return False - def skip_file (self ): - if self .isRunning ()and self .skip_current_file_flag : - self .logger ("⏭️ Skip requested for current file (single-thread mode).") - self .skip_current_file_flag .set () - else :self .logger ("ℹ️ Skip file: No download active or skip flag not available for current context.") + if self.compress_images and Image is None: + self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") + self.compress_images = False - def run (self ): + def logger(self, message): + """Emits a progress signal to be displayed in the log.""" + if hasattr(self, 'progress_signal'): + self.progress_signal.emit(str(message)) + + def run(self): """ The main execution method for the single-threaded download process. - This version is corrected to handle 7 return values from the worker and - to pass the 'single_pdf_mode' setting correctly. """ - grand_total_downloaded_files =0 - grand_total_skipped_files =0 - grand_list_of_kept_original_filenames =[] - was_process_cancelled =False - - # This block for initializing manga mode counters remains unchanged - if self .manga_mode_active and self .manga_filename_style ==STYLE_DATE_BASED and not self .extract_links_only and self .manga_date_file_counter_ref is None : - # ... (existing manga counter initialization logic) ... - pass - if self .manga_mode_active and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not self .extract_links_only and self .manga_global_file_counter_ref is None : - # ... (existing manga counter initialization logic) ... - pass + grand_total_downloaded_files = 0 + grand_total_skipped_files = 0 + grand_list_of_kept_original_filenames = [] + was_process_cancelled = False worker_signals_obj = PostProcessorSignals() - try : + try: # Connect signals worker_signals_obj.progress_signal.connect(self.progress_signal) worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal) @@ -1917,7 +1678,7 @@ class DownloadThread (QThread ): worker_signals_obj.external_link_signal.connect(self.external_link_signal) worker_signals_obj.missed_character_post_signal.connect(self.missed_character_post_signal) worker_signals_obj.file_successfully_downloaded_signal.connect(self.file_successfully_downloaded_signal) - worker_signals_obj.worker_finished_signal.connect(lambda result: None) # Connect to dummy lambda to avoid errors + worker_signals_obj.worker_finished_signal.connect(lambda result: None) self.logger(" Starting post fetch (single-threaded download process)...") post_generator = download_from_api( @@ -1932,7 +1693,9 @@ class DownloadThread (QThread ): cookie_text=self.cookie_text, selected_cookie_file=self.selected_cookie_file, app_base_dir=self.app_base_dir, - manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None + manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None, + # --- FIX: ADDED A COMMA to the line above --- + processed_post_ids=self.processed_post_ids ) for posts_batch_data in post_generator: @@ -1943,8 +1706,7 @@ class DownloadThread (QThread ): if self.isInterruptionRequested(): was_process_cancelled = True break - - # Create the worker, now correctly passing single_pdf_mode + post_processing_worker = PostProcessorWorker( post_data=individual_post_data, download_root=self.output_dir, @@ -1993,18 +1755,17 @@ class DownloadThread (QThread ): session_lock=self.session_lock, text_only_scope=self.text_only_scope, text_export_format=self.text_export_format, - single_pdf_mode=self.single_pdf_mode, # <-- This is now correctly passed + single_pdf_mode=self.single_pdf_mode, project_root_dir=self.project_root_dir ) try: - # Correctly unpack the 7 values returned from the worker (dl_count, skip_count, kept_originals_this_post, retryable_failures, permanent_failures, history_data, temp_filepath) = post_processing_worker.process() - + grand_total_downloaded_files += dl_count grand_total_skipped_files += skip_count - + if kept_originals_this_post: grand_list_of_kept_original_filenames.extend(kept_originals_this_post) if retryable_failures: @@ -2014,8 +1775,7 @@ class DownloadThread (QThread ): self.post_processed_for_history_signal.emit(history_data) if permanent_failures: self.permanent_file_failed_signal.emit(permanent_failures) - - # In single-threaded text mode, pass the temp file path back to the main window + if self.single_pdf_mode and temp_filepath: self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}") @@ -2023,7 +1783,8 @@ class DownloadThread (QThread ): post_id_for_err = individual_post_data.get('id', 'N/A') self.logger(f"❌ Error processing post {post_id_for_err} in DownloadThread: {proc_err}") traceback.print_exc() - num_potential_files_est = len(individual_post_data.get('attachments', [])) + (1 if individual_post_data.get('file') else 0) + num_potential_files_est = len(individual_post_data.get('attachments', [])) + ( + 1 if individual_post_data.get('file') else 0) grand_total_skipped_files += num_potential_files_est if self.skip_current_file_flag and self.skip_current_file_flag.is_set(): @@ -2050,7 +1811,7 @@ class DownloadThread (QThread ): worker_signals_obj.file_successfully_downloaded_signal.disconnect(self.file_successfully_downloaded_signal) except (TypeError, RuntimeError) as e: self.logger(f"ℹ️ Note during DownloadThread signal disconnection: {e}") - + # Emit the final signal with all collected results self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames) diff --git a/src/ui/dialogs/CookieHelpDialog.py b/src/ui/dialogs/CookieHelpDialog.py index 248cf29..a7aaf1c 100644 --- a/src/ui/dialogs/CookieHelpDialog.py +++ b/src/ui/dialogs/CookieHelpDialog.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import ( # --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object - +from ...utils.resolution import get_dark_theme class CookieHelpDialog(QDialog): """ diff --git a/src/ui/dialogs/DownloadExtractedLinksDialog.py b/src/ui/dialogs/DownloadExtractedLinksDialog.py index d635153..99236ec 100644 --- a/src/ui/dialogs/DownloadExtractedLinksDialog.py +++ b/src/ui/dialogs/DownloadExtractedLinksDialog.py @@ -13,7 +13,7 @@ from PyQt5.QtWidgets import ( from ...i18n.translator import get_translation # get_app_icon_object is defined in the main window module in this refactoring plan. from ..main_window import get_app_icon_object - +from ...utils.resolution import get_dark_theme class DownloadExtractedLinksDialog(QDialog): """ @@ -141,19 +141,25 @@ class DownloadExtractedLinksDialog(QDialog): self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All")) self.download_button.setText(self._tr("download_selected_button_text", "Download Selected")) self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel")) - + def _apply_theme(self): """Applies the current theme from the parent application.""" - is_dark_theme = self.parent() and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark" + is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark" + + if is_dark_theme: + # Get the scale factor from the parent app + scale = getattr(self.parent_app, 'scale_factor', 1) + # Call the imported function with the correct scale + self.setStyleSheet(get_dark_theme(scale)) + else: + # Explicitly set a blank stylesheet for light mode + self.setStyleSheet("") - if is_dark_theme and hasattr(self.parent_app, 'get_dark_theme'): - self.setStyleSheet(self.parent_app.get_dark_theme()) - # Set header text color based on theme header_color = Qt.cyan if is_dark_theme else Qt.blue for i in range(self.links_list_widget.count()): item = self.links_list_widget.item(i) - # Headers are not checkable + # Headers are not checkable (they have no checkable flag) if not item.flags() & Qt.ItemIsUserCheckable: item.setForeground(header_color) diff --git a/src/ui/dialogs/DownloadHistoryDialog.py b/src/ui/dialogs/DownloadHistoryDialog.py index f25922d..7855fe8 100644 --- a/src/ui/dialogs/DownloadHistoryDialog.py +++ b/src/ui/dialogs/DownloadHistoryDialog.py @@ -13,6 +13,7 @@ from PyQt5.QtWidgets import ( # --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object +from ...utils.resolution import get_dark_theme class DownloadHistoryDialog (QDialog ): @@ -23,7 +24,7 @@ class DownloadHistoryDialog (QDialog ): self .last_3_downloaded_entries =last_3_downloaded_entries self .first_processed_entries =first_processed_entries self .setModal (True ) - + self._apply_theme() # Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available creator_name_cache = getattr(parent_app, 'creator_name_cache', None) if creator_name_cache: @@ -158,6 +159,14 @@ class DownloadHistoryDialog (QDialog ): return get_translation (self .parent_app .current_selected_language ,key ,default_text ) return default_text + def _apply_theme(self): + """Applies the current theme from the parent application.""" + if self.parent_app and self.parent_app.current_theme == "dark": + scale = getattr(self.parent_app, 'scale_factor', 1) + self.setStyleSheet(get_dark_theme(scale)) + else: + self.setStyleSheet("QDialog { background-color: #f0f0f0; }") + def _save_history_to_txt (self ): if not self .last_3_downloaded_entries and not self .first_processed_entries : QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"), diff --git a/src/ui/dialogs/EmptyPopupDialog.py b/src/ui/dialogs/EmptyPopupDialog.py index d2fddad..ae8d5f4 100644 --- a/src/ui/dialogs/EmptyPopupDialog.py +++ b/src/ui/dialogs/EmptyPopupDialog.py @@ -21,6 +21,7 @@ from ...i18n.translator import get_translation from ..main_window import get_app_icon_object from ...core.api_client import download_from_api from ...utils.network_utils import extract_post_info, prepare_cookies_for_request +from ...utils.resolution import get_dark_theme class PostsFetcherThread (QThread ): @@ -129,6 +130,7 @@ class PostsFetcherThread (QThread ): self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators.")) self .finished_signal .emit () + class EmptyPopupDialog (QDialog ): """A simple empty popup dialog.""" SCOPE_CHARACTERS ="Characters" @@ -289,9 +291,14 @@ class EmptyPopupDialog (QDialog ): self ._retranslate_ui () - if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark": - self .setStyleSheet (self .parent_app .get_dark_theme ()) - + if self.parent_app and self.parent_app.current_theme == "dark": + # Get the scale factor from the parent app + scale = getattr(self.parent_app, 'scale_factor', 1) + # Call the imported function with the correct scale + self.setStyleSheet(get_dark_theme(scale)) + else: + # Explicitly set a blank stylesheet for light mode + self.setStyleSheet("") self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor )) @@ -997,4 +1004,4 @@ class EmptyPopupDialog (QDialog ): else : if unique_key in self .globally_selected_creators : del self .globally_selected_creators [unique_key ] - self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators )) \ No newline at end of file + self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators )) diff --git a/src/ui/dialogs/ErrorFilesDialog.py b/src/ui/dialogs/ErrorFilesDialog.py index 7e9baa7..4712d30 100644 --- a/src/ui/dialogs/ErrorFilesDialog.py +++ b/src/ui/dialogs/ErrorFilesDialog.py @@ -10,7 +10,7 @@ from ...i18n.translator import get_translation from ..assets import get_app_icon_object # Corrected Import: The filename uses PascalCase. from .ExportOptionsDialog import ExportOptionsDialog - +from ...utils.resolution import get_dark_theme class ErrorFilesDialog(QDialog): """ @@ -132,9 +132,14 @@ class ErrorFilesDialog(QDialog): def _apply_theme(self): """Applies the current theme from the parent application.""" - if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": - if hasattr(self.parent_app, 'get_dark_theme'): - self.setStyleSheet(self.parent_app.get_dark_theme()) + if self.parent_app and self.parent_app.current_theme == "dark": + # Get the scale factor from the parent app + scale = getattr(self.parent_app, 'scale_factor', 1) + # Call the imported function with the correct scale + self.setStyleSheet(get_dark_theme(scale)) + else: + # Explicitly set a blank stylesheet for light mode + self.setStyleSheet("") def _select_all_items(self): """Checks all items in the list.""" diff --git a/src/ui/dialogs/ExportOptionsDialog.py b/src/ui/dialogs/ExportOptionsDialog.py index 5723bcc..d78b086 100644 --- a/src/ui/dialogs/ExportOptionsDialog.py +++ b/src/ui/dialogs/ExportOptionsDialog.py @@ -10,7 +10,7 @@ from PyQt5.QtWidgets import ( from ...i18n.translator import get_translation # get_app_icon_object is defined in the main window module in this refactoring plan. from ..main_window import get_app_icon_object - +from ...utils.resolution import get_dark_theme class ExportOptionsDialog(QDialog): """ diff --git a/src/ui/dialogs/FavoriteArtistsDialog.py b/src/ui/dialogs/FavoriteArtistsDialog.py index 9b8c1fb..4261281 100644 --- a/src/ui/dialogs/FavoriteArtistsDialog.py +++ b/src/ui/dialogs/FavoriteArtistsDialog.py @@ -16,7 +16,7 @@ from ...i18n.translator import get_translation from ..assets import get_app_icon_object from ...utils.network_utils import prepare_cookies_for_request from .CookieHelpDialog import CookieHelpDialog - +from ...utils.resolution import get_dark_theme class FavoriteArtistsDialog (QDialog ): """Dialog to display and select favorite artists.""" diff --git a/src/ui/dialogs/FavoritePostsDialog.py b/src/ui/dialogs/FavoritePostsDialog.py index 3b94078..df0210e 100644 --- a/src/ui/dialogs/FavoritePostsDialog.py +++ b/src/ui/dialogs/FavoritePostsDialog.py @@ -25,7 +25,7 @@ from ...utils.network_utils import prepare_cookies_for_request # Corrected Import: Import CookieHelpDialog directly from its own module from .CookieHelpDialog import CookieHelpDialog from ...core.api_client import download_from_api - +from ...utils.resolution import get_dark_theme class FavoritePostsFetcherThread (QThread ): """Worker thread to fetch favorite posts and creator names.""" diff --git a/src/ui/dialogs/FutureSettingsDialog.py b/src/ui/dialogs/FutureSettingsDialog.py index ee0261b..23abc69 100644 --- a/src/ui/dialogs/FutureSettingsDialog.py +++ b/src/ui/dialogs/FutureSettingsDialog.py @@ -11,6 +11,7 @@ from PyQt5.QtWidgets import ( # --- Local Application Imports --- # This assumes the new project structure is in place. from ...i18n.translator import get_translation +from ...utils.resolution import get_dark_theme from ..main_window import get_app_icon_object from ...config.constants import ( THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY @@ -113,8 +114,9 @@ class FutureSettingsDialog(QDialog): def _apply_theme(self): """Applies the current theme from the parent application.""" - if self.parent_app.current_theme == "dark": - self.setStyleSheet(self.parent_app.get_dark_theme()) + if self.parent_app and self.parent_app.current_theme == "dark": + scale = getattr(self.parent_app, 'scale_factor', 1) + self.setStyleSheet(get_dark_theme(scale)) else: self.setStyleSheet("") diff --git a/src/ui/dialogs/HelpGuideDialog.py b/src/ui/dialogs/HelpGuideDialog.py index 8c9a634..5bdc361 100644 --- a/src/ui/dialogs/HelpGuideDialog.py +++ b/src/ui/dialogs/HelpGuideDialog.py @@ -13,7 +13,7 @@ from PyQt5.QtWidgets import ( # --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object - +from ...utils.resolution import get_dark_theme class TourStepWidget(QWidget): """ diff --git a/src/ui/dialogs/KnownNamesFilterDialog.py b/src/ui/dialogs/KnownNamesFilterDialog.py index c94af6d..4e6a09c 100644 --- a/src/ui/dialogs/KnownNamesFilterDialog.py +++ b/src/ui/dialogs/KnownNamesFilterDialog.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import ( # --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object - +from ...utils.resolution import get_dark_theme class KnownNamesFilterDialog(QDialog): """ @@ -102,8 +102,14 @@ class KnownNamesFilterDialog(QDialog): def _apply_theme(self): """Applies the current theme from the parent application.""" - if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": - self.setStyleSheet(self.parent_app.get_dark_theme()) + if self.parent_app and self.parent_app.current_theme == "dark": + # Get the scale factor from the parent app + scale = getattr(self.parent_app, 'scale_factor', 1) + # Call the imported function with the correct scale + self.setStyleSheet(get_dark_theme(scale)) + else: + # Explicitly set a blank stylesheet for light mode + self.setStyleSheet("") def _populate_list_widget(self): """Populates the list widget with the known names.""" diff --git a/src/ui/dialogs/MoreOptionsDialog.py b/src/ui/dialogs/MoreOptionsDialog.py index b9d4e68..5ead46f 100644 --- a/src/ui/dialogs/MoreOptionsDialog.py +++ b/src/ui/dialogs/MoreOptionsDialog.py @@ -2,6 +2,7 @@ from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox ) from PyQt5.QtCore import Qt +from ...utils.resolution import get_dark_theme class MoreOptionsDialog(QDialog): """ @@ -12,6 +13,7 @@ class MoreOptionsDialog(QDialog): def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False): super().__init__(parent) + self.parent_app = parent self.setWindowTitle("More Options") self.setMinimumWidth(350) @@ -62,7 +64,7 @@ class MoreOptionsDialog(QDialog): self.button_box.rejected.connect(self.reject) layout.addWidget(self.button_box) self.setLayout(layout) - + self._apply_theme() def update_single_pdf_checkbox_state(self, text): """Enable the Single PDF checkbox only if the format is PDF.""" is_pdf = (text.upper() == "PDF") @@ -80,4 +82,15 @@ class MoreOptionsDialog(QDialog): def get_single_pdf_state(self): """Returns the state of the Single PDF checkbox.""" - return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled() \ No newline at end of file + return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled() + + def _apply_theme(self): + """Applies the current theme from the parent application.""" + if self.parent_app and self.parent_app.current_theme == "dark": + # Get the scale factor from the parent app + scale = getattr(self.parent_app, 'scale_factor', 1) + # Call the imported function with the correct scale + self.setStyleSheet(get_dark_theme(scale)) + else: + # Explicitly set a blank stylesheet for light mode + self.setStyleSheet("") diff --git a/src/ui/dialogs/TourDialog.py b/src/ui/dialogs/TourDialog.py index 094fa69..b40aeea 100644 --- a/src/ui/dialogs/TourDialog.py +++ b/src/ui/dialogs/TourDialog.py @@ -12,6 +12,7 @@ from PyQt5.QtWidgets import ( # --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object +from ...utils.resolution import get_dark_theme from ...config.constants import ( CONFIG_ORGANIZATION_NAME ) @@ -150,8 +151,9 @@ class TourDialog(QDialog): def _apply_theme(self): """Applies the current theme from the parent application.""" - if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": - self.setStyleSheet(self.parent_app.get_dark_theme()) + if self.parent_app and self.parent_app.current_theme == "dark": + scale = getattr(self.parent_app, 'scale_factor', 1) + self.setStyleSheet(get_dark_theme(scale)) else: self.setStyleSheet("QDialog { background-color: #f0f0f0; }") diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 9ea4716..1961976 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -39,6 +39,8 @@ from .assets import get_app_icon_object from ..config.constants import * from ..utils.file_utils import KNOWN_NAMES, clean_folder_name from ..utils.network_utils import extract_post_info, prepare_cookies_for_request +from ..utils.resolution import setup_ui +from ..utils.resolution import get_dark_theme from ..i18n.translator import get_translation from .dialogs.EmptyPopupDialog import EmptyPopupDialog from .dialogs.CookieHelpDialog import CookieHelpDialog @@ -249,7 +251,7 @@ class DownloaderApp (QWidget ): self.remove_from_filename_label_widget = None self.skip_words_label_widget = None self.setWindowTitle("Kemono Downloader v6.0.0") - self.init_ui() + setup_ui(self) self._connect_signals() self.log_signal.emit("ℹ️ Local API server functionality has been removed.") self.log_signal.emit("ℹ️ 'Skip Current File' button has been removed.") @@ -269,6 +271,29 @@ class DownloaderApp (QWidget ): self._update_button_states_and_connections() self._check_for_interrupted_session() + def _create_initial_session_file(self, api_url_for_session, override_output_dir_for_session): # ADD override_output_dir_for_session + """Creates the initial session file at the start of a new download.""" + if self.is_restore_pending: + return + + self.log_signal.emit("📝 Creating initial session file for this download...") + initial_ui_settings = self._get_current_ui_settings_as_dict( + api_url_override=api_url_for_session, + output_dir_override=override_output_dir_for_session + ) + + session_data = { + "ui_settings": initial_ui_settings, + "download_state": { + "processed_post_ids": [], + "permanently_failed_files": [], + "manga_counters": { + "date_based": 1, + "global_numbering": 1 + } + } + } + self._save_session_file(session_data) def get_checkbox_map(self): """Returns a mapping of checkbox attribute names to their corresponding settings key.""" @@ -617,22 +642,7 @@ class DownloaderApp (QWidget ): if hasattr (self ,'known_names_help_button'):self .known_names_help_button .setToolTip (self ._tr ("known_names_help_button_tooltip_text","Open the application feature guide.")) if hasattr (self ,'future_settings_button'):self .future_settings_button .setToolTip (self ._tr ("future_settings_button_tooltip_text","Open application settings...")) if hasattr (self ,'link_search_button'):self .link_search_button .setToolTip (self ._tr ("link_search_button_tooltip_text","Filter displayed links")) - def apply_theme (self ,theme_name ,initial_load =False ): - self .current_theme =theme_name - if not initial_load : - self .settings .setValue (THEME_KEY ,theme_name ) - self .settings .sync () - - if theme_name =="dark": - self .setStyleSheet (self .get_dark_theme ()) - if not initial_load : - self .log_signal .emit ("🎨 Switched to Dark Mode.") - else : - self .setStyleSheet ("") - if not initial_load : - self .log_signal .emit ("🎨 Switched to Light Mode.") - self .update () - + def _get_tooltip_for_character_input (self ): return ( self ._tr ("character_input_tooltip","Default tooltip if translation fails.") @@ -994,469 +1004,6 @@ class DownloaderApp (QWidget ): QMessageBox .critical (self ,"Restart Failed", f"Could not automatically restart the application: {e }\n\nPlease restart it manually.") - def init_ui(self): - self.main_splitter = QSplitter(Qt.Horizontal) - - # --- Use a scroll area for the left panel for consistency --- - left_scroll_area = QScrollArea() - left_scroll_area.setWidgetResizable(True) - left_scroll_area.setFrameShape(QFrame.NoFrame) - - left_panel_widget = QWidget() - left_layout = QVBoxLayout(left_panel_widget) - left_scroll_area.setWidget(left_panel_widget) - - right_panel_widget = QWidget() - right_layout = QVBoxLayout(right_panel_widget) - - left_layout.setContentsMargins(10, 10, 10, 10) - right_layout.setContentsMargins(10, 10, 10, 10) - self.apply_theme(self.current_theme, initial_load=True) - - # --- URL and Page Range --- - self.url_input_widget = QWidget() - url_input_layout = QHBoxLayout(self.url_input_widget) - url_input_layout.setContentsMargins(0, 0, 0, 0) - self.url_label_widget = QLabel() - url_input_layout.addWidget(self.url_label_widget) - self.link_input = QLineEdit() - self.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765") - self.link_input.textChanged.connect(self.update_custom_folder_visibility) # Connects the custom folder logic - url_input_layout.addWidget(self.link_input, 1) - self.empty_popup_button = QPushButton("🎨") - self.empty_popup_button.setStyleSheet("padding: 4px 6px;") - self.empty_popup_button.clicked.connect(self._show_empty_popup) - url_input_layout.addWidget(self.empty_popup_button) - self.page_range_label = QLabel(self._tr("page_range_label_text", "Page Range:")) - self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") - url_input_layout.addWidget(self.page_range_label) - self.start_page_input = QLineEdit() - self.start_page_input.setPlaceholderText(self._tr("start_page_input_placeholder", "Start")) - self.start_page_input.setFixedWidth(50) - self.start_page_input.setValidator(QIntValidator(1, 99999)) - url_input_layout.addWidget(self.start_page_input) - self.to_label = QLabel(self._tr("page_range_to_label_text", "to")) - url_input_layout.addWidget(self.to_label) - self.end_page_input = QLineEdit() - self.end_page_input.setPlaceholderText(self._tr("end_page_input_placeholder", "End")) - self.end_page_input.setFixedWidth(50) - self.end_page_input.setToolTip(self._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number...")) - self.end_page_input.setValidator(QIntValidator(1, 99999)) - url_input_layout.addWidget(self.end_page_input) - self.url_placeholder_widget = QWidget() - placeholder_layout = QHBoxLayout(self.url_placeholder_widget) - placeholder_layout.setContentsMargins(0, 0, 0, 0) - self.fav_mode_active_label = QLabel(self._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active...")) - self.fav_mode_active_label.setAlignment(Qt.AlignCenter) - placeholder_layout.addWidget(self.fav_mode_active_label) - self.url_or_placeholder_stack = QStackedWidget() - self.url_or_placeholder_stack.addWidget(self.url_input_widget) - self.url_or_placeholder_stack.addWidget(self.url_placeholder_widget) - left_layout.addWidget(self.url_or_placeholder_stack) - - # --- Download Location --- - self.download_location_label_widget = QLabel() - left_layout.addWidget(self.download_location_label_widget) - dir_layout = QHBoxLayout() - self.dir_input = QLineEdit() - self.dir_input.setPlaceholderText("Select folder where downloads will be saved") - self.dir_button = QPushButton("Browse...") - self.dir_button.setStyleSheet("padding: 4px 10px;") - self.dir_button.clicked.connect(self.browse_directory) - dir_layout.addWidget(self.dir_input, 1) - dir_layout.addWidget(self.dir_button) - left_layout.addLayout(dir_layout) - - # --- Filters and Custom Folder Container (from old layout) --- - self.filters_and_custom_folder_container_widget = QWidget() - filters_and_custom_folder_layout = QHBoxLayout(self.filters_and_custom_folder_container_widget) - filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0) - filters_and_custom_folder_layout.setSpacing(10) - self.character_filter_widget = QWidget() - character_filter_v_layout = QVBoxLayout(self.character_filter_widget) - character_filter_v_layout.setContentsMargins(0, 0, 0, 0) - character_filter_v_layout.setSpacing(2) - self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):") - character_filter_v_layout.addWidget(self.character_label) - char_input_and_button_layout = QHBoxLayout() - char_input_and_button_layout.setContentsMargins(0, 0, 0, 0) - char_input_and_button_layout.setSpacing(10) - self.character_input = QLineEdit() - self.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)") - char_input_and_button_layout.addWidget(self.character_input, 3) - self.char_filter_scope_toggle_button = QPushButton() - self._update_char_filter_scope_button_text() - char_input_and_button_layout.addWidget(self.char_filter_scope_toggle_button, 1) - character_filter_v_layout.addLayout(char_input_and_button_layout) - - # --- Custom Folder Widget Definition --- - self.custom_folder_widget = QWidget() - custom_folder_v_layout = QVBoxLayout(self.custom_folder_widget) - custom_folder_v_layout.setContentsMargins(0, 0, 0, 0) - custom_folder_v_layout.setSpacing(2) - self.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):") - self.custom_folder_input = QLineEdit() - self.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder") - custom_folder_v_layout.addWidget(self.custom_folder_label) - custom_folder_v_layout.addWidget(self.custom_folder_input) - self.custom_folder_widget.setVisible(False) - - filters_and_custom_folder_layout.addWidget(self.character_filter_widget, 1) - filters_and_custom_folder_layout.addWidget(self.custom_folder_widget, 1) - left_layout.addWidget(self.filters_and_custom_folder_container_widget) - - # --- Word Manipulation Container --- - word_manipulation_container_widget = QWidget() - word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget) - word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0) - word_manipulation_outer_layout.setSpacing(15) - skip_words_widget = QWidget() - skip_words_vertical_layout = QVBoxLayout(skip_words_widget) - skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0) - skip_words_vertical_layout.setSpacing(2) - self.skip_words_label_widget = QLabel() - skip_words_vertical_layout.addWidget(self.skip_words_label_widget) - skip_input_and_button_layout = QHBoxLayout() - skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0) - skip_input_and_button_layout.setSpacing(10) - self.skip_words_input = QLineEdit() - self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview") - skip_input_and_button_layout.addWidget(self.skip_words_input, 1) - self.skip_scope_toggle_button = QPushButton() - self._update_skip_scope_button_text() - skip_input_and_button_layout.addWidget(self.skip_scope_toggle_button, 0) - skip_words_vertical_layout.addLayout(skip_input_and_button_layout) - word_manipulation_outer_layout.addWidget(skip_words_widget, 7) - remove_words_widget = QWidget() - remove_words_vertical_layout = QVBoxLayout(remove_words_widget) - remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0) - remove_words_vertical_layout.setSpacing(2) - self.remove_from_filename_label_widget = QLabel() - remove_words_vertical_layout.addWidget(self.remove_from_filename_label_widget) - self.remove_from_filename_input = QLineEdit() - self.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD") - remove_words_vertical_layout.addWidget(self.remove_from_filename_input) - word_manipulation_outer_layout.addWidget(remove_words_widget, 3) - left_layout.addWidget(word_manipulation_container_widget) - - # --- File Filter Layout --- - file_filter_layout = QVBoxLayout() - file_filter_layout.setContentsMargins(0, 10, 0, 0) - file_filter_layout.addWidget(QLabel("Filter Files:")) - radio_button_layout = QHBoxLayout() - radio_button_layout.setSpacing(10) - self.radio_group = QButtonGroup(self) - self.radio_all = QRadioButton("All") - self.radio_images = QRadioButton("Images/GIFs") - self.radio_videos = QRadioButton("Videos") - self.radio_only_archives = QRadioButton("📦 Only Archives") - self.radio_only_audio = QRadioButton("🎧 Only Audio") - self.radio_only_links = QRadioButton("🔗 Only Links") - self.radio_more = QRadioButton("More") - - self.radio_all.setChecked(True) - for btn in [self.radio_all, self.radio_images, self.radio_videos, self.radio_only_archives, self.radio_only_audio, self.radio_only_links, self.radio_more]: - self.radio_group.addButton(btn) - radio_button_layout.addWidget(btn) - self.favorite_mode_checkbox = QCheckBox() - self.favorite_mode_checkbox.setChecked(False) - radio_button_layout.addWidget(self.favorite_mode_checkbox) - radio_button_layout.addStretch(1) - file_filter_layout.addLayout(radio_button_layout) - left_layout.addLayout(file_filter_layout) - - # --- Checkboxes Group --- - checkboxes_group_layout = QVBoxLayout() - checkboxes_group_layout.setSpacing(10) - row1_layout = QHBoxLayout() - row1_layout.setSpacing(10) - self.skip_zip_checkbox = QCheckBox("Skip .zip") - self.skip_zip_checkbox.setChecked(True) - row1_layout.addWidget(self.skip_zip_checkbox) - self.skip_rar_checkbox = QCheckBox("Skip .rar") - self.skip_rar_checkbox.setChecked(True) - row1_layout.addWidget(self.skip_rar_checkbox) - self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") - row1_layout.addWidget(self.download_thumbnails_checkbox) - self.scan_content_images_checkbox = QCheckBox("Scan Content for Images") - self.scan_content_images_checkbox.setChecked(self.scan_content_images_setting) - row1_layout.addWidget(self.scan_content_images_checkbox) - self.compress_images_checkbox = QCheckBox("Compress to WebP") - self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).") - row1_layout.addWidget(self.compress_images_checkbox) - self.keep_duplicates_checkbox = QCheckBox("Keep Duplicates") - self.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.") - row1_layout.addWidget(self.keep_duplicates_checkbox) - row1_layout.addStretch(1) - checkboxes_group_layout.addLayout(row1_layout) - - # --- Advanced Settings --- - advanced_settings_label = QLabel("⚙️ Advanced Settings:") - checkboxes_group_layout.addWidget(advanced_settings_label) - advanced_row1_layout = QHBoxLayout() - advanced_row1_layout.setSpacing(10) - self.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title") - self.use_subfolders_checkbox.setChecked(True) - self.use_subfolders_checkbox.toggled.connect(self.update_ui_for_subfolders) - advanced_row1_layout.addWidget(self.use_subfolders_checkbox) - self.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post") - self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders) - advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox) - self.date_prefix_checkbox = QCheckBox("Date Prefix") - self.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") - advanced_row1_layout.addWidget(self.date_prefix_checkbox) - self.use_cookie_checkbox = QCheckBox("Use Cookie") - self.use_cookie_checkbox.setChecked(self.use_cookie_setting) - self.cookie_text_input = QLineEdit() - self.cookie_text_input.setPlaceholderText("if no Select cookies.txt)") - self.cookie_text_input.setText(self.cookie_text_setting) - advanced_row1_layout.addWidget(self.use_cookie_checkbox) - advanced_row1_layout.addWidget(self.cookie_text_input, 2) - self.cookie_browse_button = QPushButton("Browse...") - self.cookie_browse_button.setFixedWidth(80) - self.cookie_browse_button.setStyleSheet("padding: 4px 8px;") - advanced_row1_layout.addWidget(self.cookie_browse_button) - advanced_row1_layout.addStretch(1) - checkboxes_group_layout.addLayout(advanced_row1_layout) - advanced_row2_layout = QHBoxLayout() - advanced_row2_layout.setSpacing(10) - multithreading_layout = QHBoxLayout() - multithreading_layout.setContentsMargins(0, 0, 0, 0) - self.use_multithreading_checkbox = QCheckBox("Use Multithreading") - self.use_multithreading_checkbox.setChecked(True) - multithreading_layout.addWidget(self.use_multithreading_checkbox) - self.thread_count_label = QLabel("Threads:") - multithreading_layout.addWidget(self.thread_count_label) - self.thread_count_input = QLineEdit("4") - self.thread_count_input.setFixedWidth(40) - self.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS)) - multithreading_layout.addWidget(self.thread_count_input) - advanced_row2_layout.addLayout(multithreading_layout) - self.external_links_checkbox = QCheckBox("Show External Links in Log") - advanced_row2_layout.addWidget(self.external_links_checkbox) - self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode") - advanced_row2_layout.addWidget(self.manga_mode_checkbox) - advanced_row2_layout.addStretch(1) - checkboxes_group_layout.addLayout(advanced_row2_layout) - left_layout.addLayout(checkboxes_group_layout) - - # --- Action Buttons --- - self.standard_action_buttons_widget = QWidget() - btn_layout = QHBoxLayout(self.standard_action_buttons_widget) - btn_layout.setContentsMargins(0, 10, 0, 0) - btn_layout.setSpacing(10) - self.download_btn = QPushButton("⬇️ Start Download") - self.download_btn.setStyleSheet("padding: 4px 12px; font-weight: bold;") - self.download_btn.clicked.connect(self.start_download) - self.pause_btn = QPushButton("⏸️ Pause Download") - self.pause_btn.setEnabled(False) - self.pause_btn.setStyleSheet("padding: 4px 12px;") - self.pause_btn.clicked.connect(self._handle_pause_resume_action) - self.cancel_btn = QPushButton("❌ Cancel & Reset UI") - self.cancel_btn.setEnabled(False) - self.cancel_btn.setStyleSheet("padding: 4px 12px;") - self.cancel_btn.clicked.connect(self.cancel_download_button_action) - self.error_btn = QPushButton("Error") - self.error_btn.setToolTip("View files skipped due to errors and optionally retry them.") - self.error_btn.setStyleSheet("padding: 4px 8px;") - self.error_btn.setEnabled(True) - btn_layout.addWidget(self.download_btn) - btn_layout.addWidget(self.pause_btn) - btn_layout.addWidget(self.cancel_btn) - btn_layout.addWidget(self.error_btn) - self.favorite_action_buttons_widget = QWidget() - favorite_buttons_layout = QHBoxLayout(self.favorite_action_buttons_widget) - self.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists") - self.favorite_mode_posts_button = QPushButton("📄 Favorite Posts") - self.favorite_scope_toggle_button = QPushButton() - favorite_buttons_layout.addWidget(self.favorite_mode_artists_button) - favorite_buttons_layout.addWidget(self.favorite_mode_posts_button) - favorite_buttons_layout.addWidget(self.favorite_scope_toggle_button) - self.bottom_action_buttons_stack = QStackedWidget() - self.bottom_action_buttons_stack.addWidget(self.standard_action_buttons_widget) - self.bottom_action_buttons_stack.addWidget(self.favorite_action_buttons_widget) - left_layout.addWidget(self.bottom_action_buttons_stack) - left_layout.addSpacing(10) - - # --- Known Names Layout --- - known_chars_label_layout = QHBoxLayout() - known_chars_label_layout.setSpacing(10) - self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") - known_chars_label_layout.addWidget(self.known_chars_label) - self.open_known_txt_button = QPushButton("Open Known.txt") - self.open_known_txt_button.setStyleSheet("padding: 4px 8px;") - self.open_known_txt_button.setFixedWidth(120) - known_chars_label_layout.addWidget(self.open_known_txt_button) - self.character_search_input = QLineEdit() - self.character_search_input.setPlaceholderText("Search characters...") - known_chars_label_layout.addWidget(self.character_search_input, 1) - left_layout.addLayout(known_chars_label_layout) - self.character_list = QListWidget() - self.character_list.setSelectionMode(QListWidget.ExtendedSelection) - self.character_list.setMaximumHeight(150) # Set smaller height - left_layout.addWidget(self.character_list, 1) - char_manage_layout = QHBoxLayout() - char_manage_layout.setSpacing(10) - self.new_char_input = QLineEdit() - self.new_char_input.setPlaceholderText("Add new show/character name") - self.new_char_input.setStyleSheet("padding: 3px 5px;") - self.add_char_button = QPushButton("➕ Add") - self.add_char_button.setStyleSheet("padding: 4px 10px;") - self.add_to_filter_button = QPushButton("⤵️ Add to Filter") - self.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.") - self.add_to_filter_button.setStyleSheet("padding: 4px 10px;") - self.delete_char_button = QPushButton("🗑️ Delete Selected") - self.delete_char_button.setToolTip("Delete the selected name(s)...") - self.delete_char_button.setStyleSheet("padding: 4px 10px;") - self.add_char_button.clicked.connect(self._handle_ui_add_new_character) - self.new_char_input.returnPressed.connect(self.add_char_button.click) - self.delete_char_button.clicked.connect(self.delete_selected_character) - char_manage_layout.addWidget(self.new_char_input, 2) - char_manage_layout.addWidget(self.add_char_button, 0) - self.known_names_help_button = QPushButton("?") - self.known_names_help_button.setFixedWidth(35) - self.known_names_help_button.setStyleSheet("padding: 4px 6px;") - self.known_names_help_button.clicked.connect(self._show_feature_guide) - self.history_button = QPushButton("📜") - self.history_button.setFixedWidth(35) - self.history_button.setStyleSheet("padding: 4px 6px;") - self.history_button.setToolTip(self._tr("history_button_tooltip_text", "View download history")) - self.future_settings_button = QPushButton("⚙️") - self.future_settings_button.setFixedWidth(35) - self.future_settings_button.setStyleSheet("padding: 4px 6px;") - self.future_settings_button.clicked.connect(self._show_future_settings_dialog) - char_manage_layout.addWidget(self.add_to_filter_button, 1) - char_manage_layout.addWidget(self.delete_char_button, 1) - char_manage_layout.addWidget(self.known_names_help_button, 0) - char_manage_layout.addWidget(self.history_button, 0) - char_manage_layout.addWidget(self.future_settings_button, 0) - left_layout.addLayout(char_manage_layout) - left_layout.addStretch(0) - - # --- Right Panel (Logs) --- - # (This part of the layout is unchanged and remains correct) - log_title_layout = QHBoxLayout() - self.progress_log_label = QLabel("📜 Progress Log:") - log_title_layout.addWidget(self.progress_log_label) - log_title_layout.addStretch(1) - self.link_search_input = QLineEdit() - self.link_search_input.setPlaceholderText("Search Links...") - self.link_search_input.setVisible(False) - log_title_layout.addWidget(self.link_search_input) - self.link_search_button = QPushButton("🔍") - self.link_search_button.setVisible(False) - self.link_search_button.setFixedWidth(30) - self.link_search_button.setStyleSheet("padding: 4px 4px;") - log_title_layout.addWidget(self.link_search_button) - self.manga_rename_toggle_button = QPushButton() - self.manga_rename_toggle_button.setVisible(False) - self.manga_rename_toggle_button.setFixedWidth(140) - self.manga_rename_toggle_button.setStyleSheet("padding: 4px 8px;") - self._update_manga_filename_style_button_text() - log_title_layout.addWidget(self.manga_rename_toggle_button) - self.manga_date_prefix_input = QLineEdit() - self.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames") - self.manga_date_prefix_input.setVisible(False) - log_title_layout.addWidget(self.manga_date_prefix_input) - self.multipart_toggle_button = QPushButton() - self.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.") - self.multipart_toggle_button.setFixedWidth(130) - self.multipart_toggle_button.setStyleSheet("padding: 4px 8px;") - self._update_multipart_toggle_button_text() - log_title_layout.addWidget(self.multipart_toggle_button) - self.EYE_ICON = "\U0001F441" - self.CLOSED_EYE_ICON = "\U0001F648" - self.log_verbosity_toggle_button = QPushButton(self.EYE_ICON) - self.log_verbosity_toggle_button.setFixedWidth(45) - self.log_verbosity_toggle_button.setStyleSheet("font-size: 11pt; padding: 4px 2px;") - log_title_layout.addWidget(self.log_verbosity_toggle_button) - self.reset_button = QPushButton("🔄 Reset") - self.reset_button.setFixedWidth(80) - self.reset_button.setStyleSheet("padding: 4px 8px;") - log_title_layout.addWidget(self.reset_button) - right_layout.addLayout(log_title_layout) - self.log_splitter = QSplitter(Qt.Vertical) - self.log_view_stack = QStackedWidget() - self.main_log_output = QTextEdit() - self.main_log_output.setReadOnly(True) - self.main_log_output.setLineWrapMode(QTextEdit.NoWrap) - self.log_view_stack.addWidget(self.main_log_output) - self.missed_character_log_output = QTextEdit() - self.missed_character_log_output.setReadOnly(True) - self.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap) - self.log_view_stack.addWidget(self.missed_character_log_output) - self.external_log_output = QTextEdit() - self.external_log_output.setReadOnly(True) - self.external_log_output.setLineWrapMode(QTextEdit.NoWrap) - self.external_log_output.hide() - self.log_splitter.addWidget(self.log_view_stack) - self.log_splitter.addWidget(self.external_log_output) - self.log_splitter.setSizes([self.height(), 0]) - right_layout.addWidget(self.log_splitter, 1) - export_button_layout = QHBoxLayout() - export_button_layout.addStretch(1) - self.export_links_button = QPushButton(self._tr("export_links_button_text", "Export Links")) - self.export_links_button.setFixedWidth(100) - self.export_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") - self.export_links_button.setEnabled(False) - self.export_links_button.setVisible(False) - export_button_layout.addWidget(self.export_links_button) - self.download_extracted_links_button = QPushButton(self._tr("download_extracted_links_button_text", "Download")) - self.download_extracted_links_button.setFixedWidth(100) - self.download_extracted_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") - self.download_extracted_links_button.setEnabled(False) - self.download_extracted_links_button.setVisible(False) - export_button_layout.addWidget(self.download_extracted_links_button) - self.log_display_mode_toggle_button = QPushButton() - self.log_display_mode_toggle_button.setFixedWidth(120) - self.log_display_mode_toggle_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;") - self.log_display_mode_toggle_button.setVisible(False) - export_button_layout.addWidget(self.log_display_mode_toggle_button) - right_layout.addLayout(export_button_layout) - self.progress_label = QLabel("Progress: Idle") - self.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;") - right_layout.addWidget(self.progress_label) - self.file_progress_label = QLabel("") - self.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.") - self.file_progress_label.setWordWrap(True) - self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;") - right_layout.addWidget(self.file_progress_label) - - # --- Final Assembly --- - self.main_splitter.addWidget(left_scroll_area) # Use the scroll area - self.main_splitter.addWidget(right_panel_widget) - self.main_splitter.setStretchFactor(0, 7) - self.main_splitter.setStretchFactor(1, 3) - top_level_layout = QHBoxLayout(self) - top_level_layout.setContentsMargins(0, 0, 0, 0) - top_level_layout.addWidget(self.main_splitter) - - # --- Initial UI State Updates --- - self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked()) - self.update_external_links_setting(self.external_links_checkbox.isChecked()) - self.update_multithreading_label(self.thread_count_input.text()) - self.update_page_range_enabled_state() - if self.manga_mode_checkbox: - self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) - if hasattr(self, 'link_input'): - self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)) - self._load_creator_name_cache_from_json() - self.load_known_names_from_util() - self._update_cookie_input_visibility(self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) - self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) - if hasattr(self, 'radio_group') and self.radio_group.checkedButton(): - self._handle_filter_mode_change(self.radio_group.checkedButton(), True) - self.radio_group.buttonToggled.connect(self._handle_more_options_toggled) # Add this line - - self._update_manga_filename_style_button_text() - self._update_skip_scope_button_text() - self._update_char_filter_scope_button_text() - self._update_multithreading_for_date_mode() - if hasattr(self, 'download_thumbnails_checkbox'): - self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked()) - if hasattr(self, 'favorite_mode_checkbox'): - self._handle_favorite_mode_toggle(False) - def _load_persistent_history (self ): """Loads download history from a persistent file.""" self .log_signal .emit (f"📜 Attempting to load history from: {self .persistent_history_file }") @@ -1865,33 +1412,6 @@ class DownloaderApp (QWidget ): self .log_signal .emit ("ℹ️ Browsed cookie file path cleared from input. Switched to manual cookie string mode.") - def get_dark_theme (self ): - return """ - QWidget { background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; } - QLineEdit, QListWidget { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; } - QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; - color: #F0F0F0; border-radius: 4px; - font-family: Consolas, Courier New, monospace; font-size: 9.5pt; } - /* --- FIX: Adjusted padding to match QLineEdit and removed min-height --- */ - QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 5px 12px; border-radius: 4px; } - QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; } - QPushButton:pressed { background-color: #4A4A4A; } - QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; } - QLabel { font-weight: bold; padding-top: 4px; padding-bottom: 2px; color: #C0C0C0; } - QRadioButton, QCheckBox { spacing: 5px; color: #E0E0E0; padding-top: 4px; padding-bottom: 4px; } - QRadioButton::indicator, QCheckBox::indicator { width: 14px; height: 14px; } - QListWidget { alternate-background-color: #353535; border: 1px solid #5A5A5A; } - QListWidget::item:selected { background-color: #007ACC; color: #FFFFFF; } - QToolTip { background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 4px; border-radius: 3px; } - QSplitter::handle { background-color: #5A5A5A; } - QSplitter::handle:horizontal { width: 5px; } - QSplitter::handle:vertical { height: 5px; } - QFrame[frameShape="4"], QFrame[frameShape="5"] { - border: 1px solid #4A4A4A; - border-radius: 3px; - } - """ - def browse_directory (self ): initial_dir_text =self .dir_input .text () start_path ="" @@ -2871,9 +2391,6 @@ class DownloaderApp (QWidget ): self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).") - -# In main_window.py - def _toggle_manga_filename_style (self ): current_style =self .manga_filename_style new_style ="" @@ -3077,761 +2594,613 @@ class DownloaderApp (QWidget ): if total_posts >0 or processed_posts >0 : self .file_progress_label .setText ("") + def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False): + global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER - def start_download (self ,direct_api_url =None ,override_output_dir =None, is_restore=False ): - global KNOWN_NAMES ,BackendDownloadThread ,PostProcessorWorker ,extract_post_info ,clean_folder_name ,MAX_FILE_THREADS_PER_POST_OR_WORKER + self._clear_stale_temp_files() + self.session_temp_files = [] - self._clear_stale_temp_files() - self.session_temp_files = [] + processed_post_ids_for_restore = [] + manga_counters_for_restore = None - if self ._is_download_active (): + if is_restore and self.interrupted_session_data: + self.log_signal.emit(" Restoring session state...") + download_state = self.interrupted_session_data.get("download_state", {}) + processed_post_ids_for_restore = download_state.get("processed_post_ids", []) + manga_counters_for_restore = download_state.get("manga_counters") + if processed_post_ids_for_restore: + self.log_signal.emit(f" Will skip {len(processed_post_ids_for_restore)} already processed posts.") + if manga_counters_for_restore: + self.log_signal.emit(f" Restoring manga counters: {manga_counters_for_restore}") + + if self._is_download_active(): QMessageBox.warning(self, "Busy", "A download is already in progress.") - return False + return False - - - if not direct_api_url and self .favorite_download_queue and not self .is_processing_favorites_queue : - self .log_signal .emit (f"ℹ️ Detected {len (self .favorite_download_queue )} item(s) in the queue. Starting processing...") - self .cancellation_message_logged_this_session =False - self ._process_next_favorite_download () - return True + if not direct_api_url and self.favorite_download_queue and not self.is_processing_favorites_queue: + self.log_signal.emit(f"ℹ️ Detected {len(self.favorite_download_queue)} item(s) in the queue. Starting processing...") + self.cancellation_message_logged_this_session = False + self._process_next_favorite_download() + return True if not is_restore and self.interrupted_session_data: self.log_signal.emit("ℹ️ New download started. Discarding previous interrupted session.") self._clear_session_file() self.interrupted_session_data = None self.is_restore_pending = False - api_url =direct_api_url if direct_api_url else self .link_input .text ().strip () - self .download_history_candidates .clear () - self._update_button_states_and_connections() # Ensure buttons are updated to active state + + api_url = direct_api_url if direct_api_url else self.link_input.text().strip() + + main_ui_download_dir = self.dir_input.text().strip() + extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked()) + effective_output_dir_for_run = "" + if override_output_dir: + if not main_ui_download_dir: + QMessageBox.critical(self, "Configuration Error", + "The main 'Download Location' must be set in the UI " + "before downloading favorites with 'Artist Folders' scope.") + if self.is_processing_favorites_queue: + self.log_signal.emit(f"❌ Favorite download for '{api_url}' skipped: Main download directory not set.") + return False - if self .favorite_mode_checkbox and self .favorite_mode_checkbox .isChecked ()and not direct_api_url and not api_url : - QMessageBox .information (self ,"Favorite Mode Active", - "Favorite Mode is active. Please use the 'Favorite Artists' or 'Favorite Posts' buttons to start downloads in this mode, or uncheck 'Favorite Mode' to use the URL input.") - self .set_ui_enabled (True ) - return False + if not os.path.isdir(main_ui_download_dir): + QMessageBox.critical(self, "Directory Error", + f"The main 'Download Location' ('{main_ui_download_dir}') " + "does not exist or is not a directory. Please set a valid one for 'Artist Folders' scope.") + if self.is_processing_favorites_queue: + self.log_signal.emit(f"❌ Favorite download for '{api_url}' skipped: Main download directory invalid.") + return False + effective_output_dir_for_run = os.path.normpath(override_output_dir) + else: + if not extract_links_only and not main_ui_download_dir: + QMessageBox.critical(self, "Input Error", "Download Directory is required when not in 'Only Links' mode.") + return False - main_ui_download_dir =self .dir_input .text ().strip () + if not extract_links_only and main_ui_download_dir and not os.path.isdir(main_ui_download_dir): + reply = QMessageBox.question(self, "Create Directory?", + f"The directory '{main_ui_download_dir}' does not exist.\nCreate it now?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) + if reply == QMessageBox.Yes: + try: + os.makedirs(main_ui_download_dir, exist_ok=True) + self.log_signal.emit(f"ℹ️ Created directory: {main_ui_download_dir}") + except Exception as e: + QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}") + return False + else: + self.log_signal.emit("❌ Download cancelled: Output directory does not exist and was not created.") + return False + effective_output_dir_for_run = os.path.normpath(main_ui_download_dir) - if not api_url and not self .favorite_download_queue : - QMessageBox .critical (self ,"Input Error","URL is required.") - return False - elif not api_url and self .favorite_download_queue : - self .log_signal .emit ("ℹ️ URL input is empty, but queue has items. Processing queue...") - self .cancellation_message_logged_this_session =False - self ._process_next_favorite_download () - return True + if not is_restore: + self._create_initial_session_file(api_url, effective_output_dir_for_run) - self .cancellation_message_logged_this_session =False - use_subfolders =self .use_subfolders_checkbox .isChecked () - use_post_subfolders =self .use_subfolder_per_post_checkbox .isChecked () - compress_images =self .compress_images_checkbox .isChecked () - download_thumbnails =self .download_thumbnails_checkbox .isChecked () + self.download_history_candidates.clear() + self._update_button_states_and_connections() - use_multithreading_enabled_by_checkbox =self .use_multithreading_checkbox .isChecked () - try : - num_threads_from_gui =int (self .thread_count_input .text ().strip ()) - if num_threads_from_gui <1 :num_threads_from_gui =1 - except ValueError : - QMessageBox .critical (self ,"Thread Count Error","Invalid number of threads. Please enter a positive number.") - return False + if self.favorite_mode_checkbox and self.favorite_mode_checkbox.isChecked() and not direct_api_url and not api_url: + QMessageBox.information(self, "Favorite Mode Active", + "Favorite Mode is active. Please use the 'Favorite Artists' or 'Favorite Posts' buttons to start downloads in this mode, or uncheck 'Favorite Mode' to use the URL input.") + self.set_ui_enabled(True) + return False - if use_multithreading_enabled_by_checkbox : - 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" - "Using an extremely high number of threads can lead to:\n" - " - Diminishing returns (no significant speed increase).\n" - " - Increased system instability or application crashes.\n" - " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n" - f"The thread count has been automatically capped to {MAX_THREADS } for stability." + if not api_url and not self.favorite_download_queue: + QMessageBox.critical(self, "Input Error", "URL is required.") + return False + elif not api_url and self.favorite_download_queue: + self.log_signal.emit("ℹ️ URL input is empty, but queue has items. Processing queue...") + self.cancellation_message_logged_this_session = False + self._process_next_favorite_download() + return True + + self.cancellation_message_logged_this_session = False + use_subfolders = self.use_subfolders_checkbox.isChecked() + use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked() + compress_images = self.compress_images_checkbox.isChecked() + download_thumbnails = self.download_thumbnails_checkbox.isChecked() + + use_multithreading_enabled_by_checkbox = self.use_multithreading_checkbox.isChecked() + try: + num_threads_from_gui = int(self.thread_count_input.text().strip()) + if num_threads_from_gui < 1: num_threads_from_gui = 1 + except ValueError: + QMessageBox.critical(self, "Thread Count Error", "Invalid number of threads. Please enter a positive number.") + return False + + if use_multithreading_enabled_by_checkbox: + 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" + "Using an extremely high number of threads can lead to:\n" + " - Diminishing returns (no significant speed increase).\n" + " - Increased system instability or application crashes.\n" + " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n" + f"The thread count has been automatically capped to {MAX_THREADS} for stability." ) - QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg ) - num_threads_from_gui =MAX_THREADS - self .thread_count_input .setText (str (MAX_THREADS )) - self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.") - if SOFT_WARNING_THREAD_THRESHOLD end_page: raise ValueError("Start page cannot be greater than end page.") - self .cancellation_message_logged_this_session =False - use_subfolders =self .use_subfolders_checkbox .isChecked () - use_post_subfolders =self .use_subfolder_per_post_checkbox .isChecked () - compress_images =self .compress_images_checkbox .isChecked () - download_thumbnails =self .download_thumbnails_checkbox .isChecked () - - use_multithreading_enabled_by_checkbox =self .use_multithreading_checkbox .isChecked () - try : - num_threads_from_gui =int (self .thread_count_input .text ().strip ()) - if num_threads_from_gui <1 :num_threads_from_gui =1 - except ValueError : - QMessageBox .critical (self ,"Thread Count Error","Invalid number of threads. Please enter a positive number.") - return False - - if use_multithreading_enabled_by_checkbox : - 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" - "Using an extremely high number of threads can lead to:\n" - " - Diminishing returns (no significant speed increase).\n" - " - Increased system instability or application crashes.\n" - " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n" - f"The thread count has been automatically capped to {MAX_THREADS } for stability." - ) - QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg ) - num_threads_from_gui =MAX_THREADS - self .thread_count_input .setText (str (MAX_THREADS )) - self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.") - if SOFT_WARNING_THREAD_THRESHOLD end_page :raise ValueError ("Start page cannot be greater than end page.") - - if manga_mode and start_page and end_page : - msg_box =QMessageBox (self ) - msg_box .setIcon (QMessageBox .Warning ) - msg_box .setWindowTitle ("Manga Mode & Page Range Warning") - msg_box .setText ( - "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n" - "Manga Mode processes posts from oldest to newest across all available pages by default.\n" - "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n" - "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n" - "Do you want to proceed with this page range in Manga Mode?" + if manga_mode and start_page and end_page: + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Manga Mode & Page Range Warning") + msg_box.setText( + "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n" + "Manga Mode processes posts from oldest to newest across all available pages by default.\n" + "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n" + "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n" + "Do you want to proceed with this page range in Manga Mode?" ) - proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole ) - cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole ) - msg_box .setDefaultButton (proceed_button ) - msg_box .setEscapeButton (cancel_button ) - msg_box .exec_ () + proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) + cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole) + msg_box.setDefaultButton(proceed_button) + msg_box.setEscapeButton(cancel_button) + msg_box.exec_() - if msg_box .clickedButton ()==cancel_button : - self .log_signal .emit ("❌ Download cancelled by user due to Manga Mode & Page Range warning.") - return False - except ValueError as e : - QMessageBox .critical (self ,"Page Range Error",f"Invalid page range: {e }") - return False - self .external_link_queue .clear ();self .extracted_links_cache =[];self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None + if msg_box.clickedButton() == cancel_button: + self.log_signal.emit("❌ Download cancelled by user due to Manga Mode & Page Range warning.") + return False + except ValueError as e: + QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}") + return False + 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 () - parsed_character_filter_objects =self ._parse_character_filters (raw_character_filters_text ) + raw_character_filters_text = self.character_input.text().strip() + parsed_character_filter_objects = self._parse_character_filters(raw_character_filters_text) - actual_filters_to_use_for_run =[] + actual_filters_to_use_for_run = [] - needs_folder_naming_validation =(use_subfolders or manga_mode )and not extract_links_only + needs_folder_naming_validation = (use_subfolders or manga_mode) and not extract_links_only - if parsed_character_filter_objects : - actual_filters_to_use_for_run =parsed_character_filter_objects + if parsed_character_filter_objects: + actual_filters_to_use_for_run = parsed_character_filter_objects - 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 )}") + 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)}") - filter_objects_to_potentially_add_to_known_list =[] - 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 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 + filter_objects_to_potentially_add_to_known_list = [] + 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 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 - 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 + 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 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.") - 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 + 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 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 filter_objects_to_potentially_add_to_known_list : - confirm_dialog =ConfirmAddAllDialog (filter_objects_to_potentially_add_to_known_list ,self ,self ) - dialog_result =confirm_dialog .exec_ () + if filter_objects_to_potentially_add_to_known_list: + confirm_dialog = ConfirmAddAllDialog(filter_objects_to_potentially_add_to_known_list, self, 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.") - return False - elif isinstance (dialog_result ,list ): - if dialog_result : - self .log_signal .emit (f"ℹ️ User chose to add {len (dialog_result )} new entry/entries to Known.txt.") - for filter_obj_to_add in dialog_result : - if filter_obj_to_add .get ("components_are_distinct_for_known_txt"): - self .log_signal .emit (f" Processing group '{filter_obj_to_add ['name']}' to add its components individually to Known.txt.") - for alias_component in filter_obj_to_add ["aliases"]: - self .add_new_character ( - name_to_add =alias_component , - is_group_to_add =False , - aliases_to_add =[alias_component ], - suppress_similarity_prompt =True + if dialog_result == CONFIRM_ADD_ALL_CANCEL_DOWNLOAD: + self.log_signal.emit("❌ Download cancelled by user at new name confirmation stage.") + return False + elif isinstance(dialog_result, list): + if dialog_result: + self.log_signal.emit(f"ℹ️ User chose to add {len(dialog_result)} new entry/entries to Known.txt.") + for filter_obj_to_add in dialog_result: + if filter_obj_to_add.get("components_are_distinct_for_known_txt"): + self.log_signal.emit(f" Processing group '{filter_obj_to_add['name']}' to add its components individually to Known.txt.") + for alias_component in filter_obj_to_add["aliases"]: + self.add_new_character( + name_to_add=alias_component, + is_group_to_add=False, + aliases_to_add=[alias_component], + suppress_similarity_prompt=True ) - else : - 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 + else: + 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 ) - else : - self .log_signal .emit ("ℹ️ User confirmed adding, but no names were selected in the dialog. No new names added to Known.txt.") - 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.") - else : - self .log_signal .emit (f"ℹ️ Using character filters for link extraction: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}") + else: + self.log_signal.emit("ℹ️ User confirmed adding, but no names were selected in the dialog. No new names added to Known.txt.") + 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.") + else: + self.log_signal.emit(f"ℹ️ Using character filters for link extraction: {', '.join(item['name'] for item in actual_filters_to_use_for_run)}") - self .dynamic_character_filter_holder .set_filters (actual_filters_to_use_for_run ) + self.dynamic_character_filter_holder.set_filters(actual_filters_to_use_for_run) - creator_folder_ignore_words_for_run =None - character_filters_are_empty =not actual_filters_to_use_for_run - if is_full_creator_download and character_filters_are_empty : - creator_folder_ignore_words_for_run =CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS - log_messages .append (f" Creator Download (No Char Filter): Applying default folder name ignore list ({len (creator_folder_ignore_words_for_run )} words).") + creator_folder_ignore_words_for_run = None + character_filters_are_empty = not actual_filters_to_use_for_run + if is_full_creator_download and character_filters_are_empty: + creator_folder_ignore_words_for_run = CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS + log_messages.append(f" Creator Download (No Char Filter): Applying default folder name ignore list ({len(creator_folder_ignore_words_for_run)} words).") - 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 : - raw_custom_name =self .custom_folder_input .text ().strip () - if raw_custom_name : - cleaned_custom =clean_folder_name (raw_custom_name ) - if cleaned_custom :custom_folder_name_cleaned =cleaned_custom - else :self .log_signal .emit (f"⚠️ Invalid custom folder name ignored: '{raw_custom_name }' (resulted in empty string after cleaning).") + 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: + raw_custom_name = self.custom_folder_input.text().strip() + if raw_custom_name: + cleaned_custom = clean_folder_name(raw_custom_name) + if cleaned_custom: + custom_folder_name_cleaned = cleaned_custom + else: + self.log_signal.emit(f"⚠️ Invalid custom folder name ignored: '{raw_custom_name}' (resulted in empty string after cleaning).") + self.main_log_output.clear() + if extract_links_only: self.main_log_output.append("🔗 Extracting Links..."); + elif backend_filter_mode == 'archive': self.main_log_output.append("📦 Downloading Archives Only...") - self .main_log_output .clear () - if extract_links_only :self .main_log_output .append ("🔗 Extracting Links..."); - elif backend_filter_mode =='archive':self .main_log_output .append ("📦 Downloading Archives Only...") + if self.external_log_output: self.external_log_output.clear() + if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive': + self.external_log_output.append("🔗 External Links Found:") - if self .external_log_output :self .external_log_output .clear () - if self .show_external_links and not extract_links_only and backend_filter_mode !='archive': - self .external_log_output .append ("🔗 External Links Found:") + self.file_progress_label.setText(""); + self.cancellation_event.clear(); + self.active_futures = [] + self.total_posts_to_process = 0; + self.processed_posts_count = 0; + self.download_counter = 0; + self.skip_counter = 0 + self.progress_label.setText(self._tr("progress_initializing_text", "Progress: Initializing...")) - self .file_progress_label .setText ("");self .cancellation_event .clear ();self .active_futures =[] - self .total_posts_to_process =0 ;self .processed_posts_count =0 ;self .download_counter =0 ;self .skip_counter =0 - self .progress_label .setText (self ._tr ("progress_initializing_text","Progress: Initializing...")) - - self .retryable_failed_files_info .clear () - self .permanently_failed_files_for_dialog .clear () + self.retryable_failed_files_info.clear() + self.permanently_failed_files_for_dialog.clear() self._update_error_button_count() - manga_date_file_counter_ref_for_thread =None - if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED and not extract_links_only : - 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.") + manga_date_file_counter_ref_for_thread = None + if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not extract_links_only: + start_val = 1 + if is_restore and manga_counters_for_restore: + start_val = manga_counters_for_restore.get('date_based', 1) + self.log_signal.emit(f"ℹ️ Manga Date Mode: Initializing shared file counter starting at {start_val}.") + manga_date_file_counter_ref_for_thread = [start_val, threading.Lock()] - manga_global_file_counter_ref_for_thread =None - if manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not extract_links_only : - manga_global_file_counter_ref_for_thread =None - self .log_signal .emit (f"ℹ️ Manga Title+GlobalNum Mode: File counter will be initialized by the download thread (starts at 1).") + manga_global_file_counter_ref_for_thread = None + if manga_mode and self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING and not extract_links_only: + start_val = 1 + if is_restore and manga_counters_for_restore: + start_val = manga_counters_for_restore.get('global_numbering', 1) + self.log_signal.emit(f"ℹ️ Manga Title+GlobalNum Mode: Initializing shared file counter starting at {start_val}.") + manga_global_file_counter_ref_for_thread = [start_val, threading.Lock()] - effective_num_post_workers =1 + effective_num_post_workers = 1 + effective_num_file_threads_per_worker = 1 - effective_num_file_threads_per_worker =1 + if post_id_from_url: + 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: + if manga_mode and self.manga_filename_style == STYLE_DATE_BASED: + effective_num_post_workers = 1 + elif manga_mode and self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING: + effective_num_post_workers = 1 + effective_num_file_threads_per_worker = 1 + elif use_multithreading_enabled_by_checkbox: + effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) + effective_num_file_threads_per_worker = 1 - if post_id_from_url : - 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 : - if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED : - effective_num_post_workers =1 - elif manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : - effective_num_post_workers =1 - effective_num_file_threads_per_worker =1 - elif use_multithreading_enabled_by_checkbox : - effective_num_post_workers =max (1 ,min (num_threads_from_gui ,MAX_THREADS )) - effective_num_file_threads_per_worker =1 + if not extract_links_only: log_messages.append(f" Save Location: {effective_output_dir_for_run}") - if not extract_links_only :log_messages .append (f" Save Location: {effective_output_dir_for_run }") + if post_id_from_url: + log_messages.append(f" Mode: Single Post") + log_messages.append(f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker} concurrent file(s)") + else: + log_messages.append(f" Mode: Creator Feed") + log_messages.append(f" Post Processing: {'Multi-threaded (' + str(effective_num_post_workers) + ' workers)' if effective_num_post_workers > 1 else 'Single-threaded (1 worker)'}") + log_messages.append(f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker} concurrent file(s)") + pr_log = "All" + if start_page or end_page: + pr_log = f"{f'From {start_page} ' if start_page else ''}{'to ' if start_page and end_page else ''}{f'{end_page}' if end_page else (f'Up to {end_page}' if end_page else (f'From {start_page}' if start_page else 'Specific Range'))}".strip() - if post_id_from_url : - log_messages .append (f" Mode: Single Post") - log_messages .append (f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker } concurrent file(s)") - else : - log_messages .append (f" Mode: Creator Feed") - log_messages .append (f" Post Processing: {'Multi-threaded ('+str (effective_num_post_workers )+' workers)'if effective_num_post_workers >1 else 'Single-threaded (1 worker)'}") - log_messages .append (f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker } concurrent file(s)") - pr_log ="All" - if start_page or end_page : - pr_log =f"{f'From {start_page } 'if start_page else ''}{'to 'if start_page and end_page else ''}{f'{end_page }'if end_page else (f'Up to {end_page }'if end_page else (f'From {start_page }'if start_page else 'Specific Range'))}".strip () + if manga_mode: + log_messages.append(f" Page Range: {pr_log if pr_log else 'All'} (Manga Mode - Oldest Posts Processed First within range)") + else: + log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}") - if manga_mode : - log_messages .append (f" Page Range: {pr_log if pr_log else 'All'} (Manga Mode - Oldest Posts Processed First within range)") - else : - log_messages .append (f" Page Range: {pr_log if pr_log else 'All'}") - - - if not extract_links_only : - log_messages .append (f" Subfolders: {'Enabled'if use_subfolders else 'Disabled'}") + if not extract_links_only: + log_messages.append(f" Subfolders: {'Enabled' if use_subfolders else 'Disabled'}") if use_subfolders and self.use_subfolder_per_post_checkbox.isChecked(): use_date_prefix = self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False - log_messages.append(f" ↳ Date Prefix for Post Subfolders: {'Enabled' if use_date_prefix else 'Disabled'}") - if use_subfolders : - if custom_folder_name_cleaned :log_messages .append (f" Custom Folder (Post): '{custom_folder_name_cleaned }'") - if actual_filters_to_use_for_run : - 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)") - + log_messages.append(f" ↳ Date Prefix for Post Subfolders: {'Enabled' if use_date_prefix else 'Disabled'}") + if use_subfolders: + if custom_folder_name_cleaned: log_messages.append(f" Custom Folder (Post): '{custom_folder_name_cleaned}'") + if actual_filters_to_use_for_run: + 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)") keep_duplicates = self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False log_messages.extend([ f" File Type Filter: {user_selected_filter_text} (Backend processing as: {backend_filter_mode})", f" Keep In-Post Duplicates: {'Enabled' if keep_duplicates else 'Disabled'}", f" Skip Archives: {'.zip' if effective_skip_zip else ''}{', ' if effective_skip_zip and effective_skip_rar else ''}{'.rar' if effective_skip_rar else ''}{'None (Archive Mode)' if backend_filter_mode == 'archive' else ('None' if not (effective_skip_zip or effective_skip_rar) else '')}", - f" Skip Words Scope: {current_skip_words_scope .capitalize ()}", - f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}", - f" Compress Images: {'Enabled'if compress_images else 'Disabled'}", - f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}" + f" Skip Words Scope: {current_skip_words_scope.capitalize()}", + f" Remove Words from Filename: {', '.join(remove_from_filename_words_list) if remove_from_filename_words_list else 'None'}", + f" Compress Images: {'Enabled' if compress_images else 'Disabled'}", + f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}" ]) - log_messages .append (f" Scan Post Content for Images: {'Enabled'if scan_content_for_images else 'Disabled'}") - else : - log_messages .append (f" Mode: Extracting Links Only") + log_messages.append(f" Scan Post Content for Images: {'Enabled' if scan_content_for_images else 'Disabled'}") + else: + 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'}") + 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'}") - 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 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).") + 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 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'}") - if use_cookie_from_checkbox and cookie_text_from_input : - log_messages .append (f" ↳ Cookie Text Provided: Yes (length: {len (cookie_text_from_input )})") - 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 - if manga_mode and (self .manga_filename_style ==STYLE_DATE_BASED or self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING )and not post_id_from_url : - enforced_by_style ="Date Mode"if self .manga_filename_style ==STYLE_DATE_BASED else "Title+GlobalNum Mode" - should_use_multithreading_for_posts =False - log_messages .append (f" Threading: Single-threaded (posts) - Enforced by Manga {enforced_by_style } (Actual workers: {effective_num_post_workers if effective_num_post_workers >1 else 1 })") - else : - log_messages .append (f" Threading: {'Multi-threaded (posts)'if should_use_multithreading_for_posts else 'Single-threaded (posts)'}") - if should_use_multithreading_for_posts : - log_messages .append (f" Number of Post Worker Threads: {effective_num_post_workers }") - log_messages .append ("="*40 ) - for msg in log_messages :self .log_signal .emit (msg ) - - self .set_ui_enabled (False ) + log_messages.append(f" Use Cookie ('cookies.txt'): {'Enabled' if use_cookie_from_checkbox else 'Disabled'}") + if use_cookie_from_checkbox and cookie_text_from_input: + log_messages.append(f" ↳ Cookie Text Provided: Yes (length: {len(cookie_text_from_input)})") + 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 + if manga_mode and (self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING) and not post_id_from_url: + enforced_by_style = "Date Mode" if self.manga_filename_style == STYLE_DATE_BASED else "Title+GlobalNum Mode" + should_use_multithreading_for_posts = False + log_messages.append(f" Threading: Single-threaded (posts) - Enforced by Manga {enforced_by_style} (Actual workers: {effective_num_post_workers if effective_num_post_workers > 1 else 1})") + else: + log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading_for_posts else 'Single-threaded (posts)'}") + if should_use_multithreading_for_posts: + log_messages.append(f" Number of Post Worker Threads: {effective_num_post_workers}") + log_messages.append("=" * 40) + for msg in log_messages: self.log_signal.emit(msg) + self.set_ui_enabled(False) from src.config.constants import FOLDER_NAME_STOP_WORDS - - args_template ={ - 'api_url_input':api_url , - 'download_root':effective_output_dir_for_run , - 'output_dir':effective_output_dir_for_run , - 'known_names':list (KNOWN_NAMES ), - 'known_names_copy':list (KNOWN_NAMES ), - 'filter_character_list':actual_filters_to_use_for_run , - 'filter_mode':backend_filter_mode , - 'text_only_scope': text_only_scope_for_run, - 'text_export_format': export_format_for_run, - 'single_pdf_mode': self.single_pdf_setting, - 'skip_zip':effective_skip_zip , - 'skip_rar':effective_skip_rar , - 'use_subfolders':use_subfolders , - 'use_post_subfolders':use_post_subfolders , - 'compress_images':compress_images , - 'download_thumbnails':download_thumbnails , - 'service':service , - 'user_id':user_id , - 'downloaded_files':self .downloaded_files , - 'downloaded_files_lock':self .downloaded_files_lock , - 'downloaded_file_hashes':self .downloaded_file_hashes , - 'downloaded_file_hashes_lock':self .downloaded_file_hashes_lock , - 'skip_words_list':skip_words_list , - 'skip_words_scope':current_skip_words_scope , - 'remove_from_filename_words_list':remove_from_filename_words_list , - 'char_filter_scope':current_char_filter_scope , - 'show_external_links':self .show_external_links , - 'extract_links_only':extract_links_only , - 'start_page':start_page , - 'end_page':end_page , - 'target_post_id_from_initial_url':post_id_from_url , - 'custom_folder_name':custom_folder_name_cleaned , - 'manga_mode_active':manga_mode , - 'unwanted_keywords':FOLDER_NAME_STOP_WORDS , - 'cancellation_event':self .cancellation_event , - 'manga_date_prefix':manga_date_prefix_text , - 'dynamic_character_filter_holder':self .dynamic_character_filter_holder , - 'pause_event':self .pause_event , - 'scan_content_for_images':scan_content_for_images , - '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 , - 'allow_multipart_download':allow_multipart , - 'cookie_text':cookie_text_from_input , - 'selected_cookie_file':selected_cookie_file_path_for_backend , - 'manga_global_file_counter_ref':manga_global_file_counter_ref_for_thread , - 'app_base_dir':app_base_dir_for_cookies , - 'project_root_dir': self.app_base_dir, - 'use_cookie':use_cookie_for_this_run , - 'session_file_path': self.session_file_path, - 'session_lock': self.session_lock, - 'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run , - 'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False, - 'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False, - 'skip_current_file_flag': None, + args_template = { + 'api_url_input': api_url, + 'download_root': effective_output_dir_for_run, + 'output_dir': effective_output_dir_for_run, + 'known_names': list(KNOWN_NAMES), + 'known_names_copy': list(KNOWN_NAMES), + 'filter_character_list': actual_filters_to_use_for_run, + 'filter_mode': backend_filter_mode, + 'text_only_scope': text_only_scope_for_run, + 'text_export_format': export_format_for_run, + 'single_pdf_mode': self.single_pdf_setting, + 'skip_zip': effective_skip_zip, + 'skip_rar': effective_skip_rar, + 'use_subfolders': use_subfolders, + 'use_post_subfolders': use_post_subfolders, + 'compress_images': compress_images, + 'download_thumbnails': download_thumbnails, + 'service': service, + 'user_id': user_id, + 'downloaded_files': self.downloaded_files, + 'downloaded_files_lock': self.downloaded_files_lock, + 'downloaded_file_hashes': self.downloaded_file_hashes, + 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, + 'skip_words_list': skip_words_list, + 'skip_words_scope': current_skip_words_scope, + 'remove_from_filename_words_list': remove_from_filename_words_list, + 'char_filter_scope': current_char_filter_scope, + 'show_external_links': self.show_external_links, + 'extract_links_only': extract_links_only, + 'start_page': start_page, + 'end_page': end_page, + 'target_post_id_from_initial_url': post_id_from_url, + 'custom_folder_name': custom_folder_name_cleaned, + 'manga_mode_active': manga_mode, + 'unwanted_keywords': FOLDER_NAME_STOP_WORDS, + 'cancellation_event': self.cancellation_event, + 'manga_date_prefix': manga_date_prefix_text, + 'dynamic_character_filter_holder': self.dynamic_character_filter_holder, + 'pause_event': self.pause_event, + 'scan_content_for_images': scan_content_for_images, + '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, + 'allow_multipart_download': allow_multipart, + 'cookie_text': cookie_text_from_input, + 'selected_cookie_file': selected_cookie_file_path_for_backend, + 'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread, + 'app_base_dir': app_base_dir_for_cookies, + 'project_root_dir': self.app_base_dir, + 'use_cookie': use_cookie_for_this_run, + 'session_file_path': self.session_file_path, + 'session_lock': self.session_lock, + 'creator_download_folder_ignore_words': creator_folder_ignore_words_for_run, + 'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False, + 'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False, + 'skip_current_file_flag': None, + 'processed_post_ids': processed_post_ids_for_restore, } - args_template ['override_output_dir']=override_output_dir - try : - if should_use_multithreading_for_posts : - self .log_signal .emit (f" Initializing multi-threaded {current_mode_log_text .lower ()} with {effective_num_post_workers } post workers...") - args_template ['emitter']=self .worker_to_gui_queue - 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'}...") - dt_expected_keys =[ - 'api_url_input','output_dir','known_names_copy','cancellation_event', - 'filter_character_list','filter_mode','skip_zip','skip_rar', - 'use_subfolders','use_post_subfolders','custom_folder_name', - 'compress_images','download_thumbnails','service','user_id', - 'downloaded_files','downloaded_file_hashes','pause_event','remove_from_filename_words_list', - 'downloaded_files_lock','downloaded_file_hashes_lock','dynamic_character_filter_holder', 'session_file_path', - 'session_lock', - 'skip_words_list','skip_words_scope','char_filter_scope', - 'show_external_links','extract_links_only','num_file_threads_for_worker', - 'start_page','end_page','target_post_id_from_initial_url', - 'manga_date_file_counter_ref', - 'manga_global_file_counter_ref','manga_date_prefix', - 'manga_mode_active','unwanted_keywords','manga_filename_style','scan_content_for_images', - 'allow_multipart_download','use_cookie','cookie_text','app_base_dir','selected_cookie_file','override_output_dir','project_root_dir', - 'text_only_scope', 'text_export_format', - 'single_pdf_mode' + args_template['override_output_dir'] = override_output_dir + try: + if should_use_multithreading_for_posts: + self.log_signal.emit(f" Initializing multi-threaded {current_mode_log_text.lower()} with {effective_num_post_workers} post workers...") + args_template['emitter'] = self.worker_to_gui_queue + 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'}...") + dt_expected_keys = [ + 'api_url_input', 'output_dir', 'known_names_copy', 'cancellation_event', + 'filter_character_list', 'filter_mode', 'skip_zip', 'skip_rar', + 'use_subfolders', 'use_post_subfolders', 'custom_folder_name', + 'compress_images', 'download_thumbnails', 'service', 'user_id', + 'downloaded_files', 'downloaded_file_hashes', 'pause_event', 'remove_from_filename_words_list', + 'downloaded_files_lock', 'downloaded_file_hashes_lock', 'dynamic_character_filter_holder', 'session_file_path', + 'session_lock', + 'skip_words_list', 'skip_words_scope', 'char_filter_scope', + 'show_external_links', 'extract_links_only', 'num_file_threads_for_worker', + 'start_page', 'end_page', 'target_post_id_from_initial_url', + 'manga_date_file_counter_ref', + 'manga_global_file_counter_ref', 'manga_date_prefix', + 'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'scan_content_for_images', + 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', 'override_output_dir', 'project_root_dir', + 'text_only_scope', 'text_export_format', + 'single_pdf_mode', + 'processed_post_ids' ] - args_template ['skip_current_file_flag']=None - single_thread_args ={key :args_template [key ]for key in dt_expected_keys if key in args_template } - self .start_single_threaded_download (**single_thread_args ) - except Exception as e : - self._update_button_states_and_connections() # Re-enable UI if start fails - self .log_signal .emit (f"❌ CRITICAL ERROR preparing download: {e }\n{traceback .format_exc ()}") - QMessageBox .critical (self ,"Start Error",f"Failed to start process:\n{e }") - self .download_finished (0 ,0 ,False ,[]) - if self .pause_event :self .pause_event .clear () - self .is_paused =False - return True + args_template['skip_current_file_flag'] = None + single_thread_args = {key: args_template[key] for key in dt_expected_keys if key in args_template} + self.start_single_threaded_download(**single_thread_args) + except Exception as e: + self._update_button_states_and_connections() + self.log_signal.emit(f"❌ CRITICAL ERROR preparing download: {e}\n{traceback.format_exc()}") + QMessageBox.critical(self, "Start Error", f"Failed to start process:\n{e}") + self.download_finished(0, 0, False, []) + if self.pause_event: self.pause_event.clear() + self.is_paused = False + return True def restore_download(self): """Initiates the download restoration process.""" @@ -3844,10 +3213,19 @@ class DownloaderApp (QWidget ): self._clear_session_and_reset_ui() return - self.log_signal.emit("🔄 Restoring download session...") - # The main start_download function now handles the restore logic - self.is_restore_pending = True # Set state to indicate restore is in progress - self.start_download(is_restore=True) + self.log_signal.emit("🔄 Preparing to restore download session...") + + settings = self.interrupted_session_data.get("ui_settings", {}) + restore_url = settings.get("api_url") + restore_dir = settings.get("output_dir") + + if not restore_url: + QMessageBox.critical(self, "Restore Error", "Session file is corrupt. Cannot restore because the URL is missing.") + self._clear_session_and_reset_ui() + return + + self.is_restore_pending = True + self.start_download(direct_api_url=restore_url, override_output_dir=restore_dir, is_restore=True) def start_single_threaded_download (self ,**kwargs ): global BackendDownloadThread @@ -3999,36 +3377,60 @@ class DownloaderApp (QWidget ): self._update_manga_filename_style_button_text() self._update_multipart_toggle_button_text() - def start_multi_threaded_download (self ,num_post_workers ,**kwargs ): - global PostProcessorWorker - if self .thread_pool is None : - if self .pause_event :self .pause_event .clear () - self .is_paused =False - self .thread_pool =ThreadPoolExecutor (max_workers =num_post_workers ,thread_name_prefix ='PostWorker_') - - self .active_futures =[] - self .processed_posts_count =0 ;self .total_posts_to_process =0 ;self .download_counter =0 ;self .skip_counter =0 - self .all_kept_original_filenames =[] - self .is_fetcher_thread_running =True - - fetcher_thread =threading .Thread ( - target =self ._fetch_and_queue_posts , - args =(kwargs ['api_url_input'],kwargs ,num_post_workers ), - daemon =True , - name ="PostFetcher" - ) - fetcher_thread .start () - self .log_signal .emit (f"✅ Post fetcher thread started. {num_post_workers } post worker threads initializing...") - self._update_button_states_and_connections() # Update buttons after fetcher thread starts - - def _fetch_and_queue_posts(self, api_url_input_for_fetcher, worker_args_template, num_post_workers): + def start_multi_threaded_download(self, num_post_workers, **kwargs): """ - Fetches post data and submits tasks to the pool. It does NOT wait for completion. + Initializes and starts the multi-threaded download process. + This version bundles arguments into a dictionary to prevent TypeErrors. + """ + global PostProcessorWorker + if self.thread_pool is None: + if self.pause_event: self.pause_event.clear() + self.is_paused = False + self.thread_pool = ThreadPoolExecutor(max_workers=num_post_workers, thread_name_prefix='PostWorker_') + + self.active_futures = [] + self.processed_posts_count = 0; self.total_posts_to_process = 0; self.download_counter = 0; self.skip_counter = 0 + self.all_kept_original_filenames = [] + self.is_fetcher_thread_running = True + + # --- START OF FIX --- + # Bundle all arguments for the fetcher thread into a single dictionary + # to ensure the correct number of arguments are passed. + fetcher_thread_args = { + 'api_url': kwargs.get('api_url_input'), + 'worker_args_template': kwargs, + 'num_post_workers': num_post_workers, + 'processed_post_ids': kwargs.get('processed_post_ids', []) + } + + fetcher_thread = threading.Thread( + target=self._fetch_and_queue_posts, + args=(fetcher_thread_args,), # Pass the single dictionary as an argument + daemon=True, + name="PostFetcher" + ) + # --- END OF FIX --- + + fetcher_thread.start() + self.log_signal.emit(f"✅ Post fetcher thread started. {num_post_workers} post worker threads initializing...") + self._update_button_states_and_connections() + + def _fetch_and_queue_posts(self, fetcher_args): + """ + Fetches post data and submits tasks to the pool. + This version unpacks arguments from a single dictionary. """ global PostProcessorWorker, download_from_api + + # --- START OF FIX --- + # Unpack arguments from the dictionary passed by the thread + api_url_input_for_fetcher = fetcher_args['api_url'] + worker_args_template = fetcher_args['worker_args_template'] + num_post_workers = fetcher_args['num_post_workers'] + processed_post_ids = fetcher_args['processed_post_ids'] + # --- END OF FIX --- try: - # This section remains the same as before post_generator = download_from_api( api_url_input_for_fetcher, logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), @@ -4041,7 +3443,8 @@ class DownloaderApp (QWidget ): cookie_text=worker_args_template.get('cookie_text'), selected_cookie_file=worker_args_template.get('selected_cookie_file'), app_base_dir=worker_args_template.get('app_base_dir'), - manga_filename_style_for_sort_check=worker_args_template.get('manga_filename_style') + manga_filename_style_for_sort_check=worker_args_template.get('manga_filename_style'), + processed_post_ids=processed_post_ids ) ppw_expected_keys = [ @@ -4058,7 +3461,8 @@ class DownloaderApp (QWidget ): 'manga_mode_active','manga_filename_style','manga_date_prefix','text_only_scope', 'text_export_format', 'single_pdf_mode', 'use_date_prefix_for_subfolder','keep_in_post_duplicates','manga_global_file_counter_ref', - 'creator_download_folder_ignore_words','session_file_path','project_root_dir','session_lock' + 'creator_download_folder_ignore_words','session_file_path','project_root_dir','session_lock', + 'processed_post_ids' # This key was missing ] num_file_dl_threads_for_each_worker = worker_args_template.get('num_file_threads_for_worker', 1) @@ -4076,7 +3480,6 @@ class DownloaderApp (QWidget ): except Exception as e: self.log_signal.emit(f"❌ Error during post fetching: {e}\n{traceback.format_exc(limit=2)}") finally: - # The fetcher's only job is to mark itself as done. self.is_fetcher_thread_running = False self.log_signal.emit("ℹ️ Post fetcher thread has finished submitting tasks.") @@ -4098,17 +3501,20 @@ class DownloaderApp (QWidget ): self.download_counter += downloaded self.skip_counter += skipped - # Other result handling can go here if needed + if permanent: + self.permanently_failed_files_for_dialog.extend(permanent) + self._update_error_button_count() # <-- THIS IS THE FIX + + # Other result handling if history_data: self._add_to_history_candidates(history_data) - if permanent: self.permanently_failed_files_for_dialog.extend(permanent) + # You can add handling for 'retryable' here if needed in the future 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_worker_result: {e}\n{traceback.format_exc(limit=2)}") - # THE CRITICAL CHECK: - # Is the fetcher thread done AND have we processed all the tasks it submitted? + # Check if all submitted tasks are complete if not self.is_fetcher_thread_running and self.processed_posts_count >= self.total_posts_to_process: self.log_signal.emit("🏁 All fetcher and worker tasks complete.") self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames) diff --git a/src/utils/resolution.py b/src/utils/resolution.py new file mode 100644 index 0000000..01f18be --- /dev/null +++ b/src/utils/resolution.py @@ -0,0 +1,545 @@ +# src/ui/utils/resolution.py + +# --- Standard Library Imports --- +import os + +# --- PyQt5 Imports --- +from PyQt5.QtWidgets import ( + QSplitter, QScrollArea, QFrame, QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QStackedWidget, QButtonGroup, QRadioButton, QCheckBox, + QListWidget, QTextEdit, QApplication +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIntValidator # <-- Import QIntValidator from here + +# --- Local Application Imports --- +# Assuming execution from project root +from ..config.constants import * + + +def setup_ui(main_app): + """ + Initializes and scales the user interface for the DownloaderApp. + + Args: + main_app: The instance of the main DownloaderApp. + """ + # --- START: New Scaling Logic --- + screen = QApplication.primaryScreen() + if screen: + resolution = screen.size() + if resolution.width() > 1920 and resolution.height() > 1200: + main_app.scale_factor = 2 + else: + main_app.scale_factor = 1 + else: + # Fallback if a primary screen isn't detected + main_app.scale_factor = 1 + + scale = main_app.scale_factor # Use a convenient local variable + # --- END: New Scaling Logic --- + + main_app.main_splitter = QSplitter(Qt.Horizontal) + + # --- Use a scroll area for the left panel for consistency --- + left_scroll_area = QScrollArea() + left_scroll_area.setWidgetResizable(True) + left_scroll_area.setFrameShape(QFrame.NoFrame) + + left_panel_widget = QWidget() + left_layout = QVBoxLayout(left_panel_widget) + left_scroll_area.setWidget(left_panel_widget) + + right_panel_widget = QWidget() + right_layout = QVBoxLayout(right_panel_widget) + + left_layout.setContentsMargins(10, 10, 10, 10) + right_layout.setContentsMargins(10, 10, 10, 10) + apply_theme_to_app(main_app, main_app.current_theme, initial_load=True) + + # --- URL and Page Range --- + main_app.url_input_widget = QWidget() + url_input_layout = QHBoxLayout(main_app.url_input_widget) + url_input_layout.setContentsMargins(0, 0, 0, 0) + main_app.url_label_widget = QLabel() + url_input_layout.addWidget(main_app.url_label_widget) + main_app.link_input = QLineEdit() + main_app.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765") + main_app.link_input.textChanged.connect(main_app.update_custom_folder_visibility) + url_input_layout.addWidget(main_app.link_input, 1) + main_app.empty_popup_button = QPushButton("🎨") + main_app.empty_popup_button.setStyleSheet(f"padding: {4*scale}px {6*scale}px;") + main_app.empty_popup_button.clicked.connect(main_app._show_empty_popup) + url_input_layout.addWidget(main_app.empty_popup_button) + main_app.page_range_label = QLabel(main_app._tr("page_range_label_text", "Page Range:")) + main_app.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") + url_input_layout.addWidget(main_app.page_range_label) + main_app.start_page_input = QLineEdit() + main_app.start_page_input.setPlaceholderText(main_app._tr("start_page_input_placeholder", "Start")) + main_app.start_page_input.setFixedWidth(50 * scale) + main_app.start_page_input.setValidator(QIntValidator(1, 99999)) + url_input_layout.addWidget(main_app.start_page_input) + main_app.to_label = QLabel(main_app._tr("page_range_to_label_text", "to")) + url_input_layout.addWidget(main_app.to_label) + main_app.end_page_input = QLineEdit() + main_app.end_page_input.setPlaceholderText(main_app._tr("end_page_input_placeholder", "End")) + main_app.end_page_input.setFixedWidth(50 * scale) + main_app.end_page_input.setToolTip(main_app._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number...")) + main_app.end_page_input.setValidator(QIntValidator(1, 99999)) + url_input_layout.addWidget(main_app.end_page_input) + main_app.url_placeholder_widget = QWidget() + placeholder_layout = QHBoxLayout(main_app.url_placeholder_widget) + placeholder_layout.setContentsMargins(0, 0, 0, 0) + main_app.fav_mode_active_label = QLabel(main_app._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active...")) + main_app.fav_mode_active_label.setAlignment(Qt.AlignCenter) + placeholder_layout.addWidget(main_app.fav_mode_active_label) + main_app.url_or_placeholder_stack = QStackedWidget() + main_app.url_or_placeholder_stack.addWidget(main_app.url_input_widget) + main_app.url_or_placeholder_stack.addWidget(main_app.url_placeholder_widget) + left_layout.addWidget(main_app.url_or_placeholder_stack) + + # --- Download Location --- + main_app.download_location_label_widget = QLabel() + left_layout.addWidget(main_app.download_location_label_widget) + dir_layout = QHBoxLayout() + main_app.dir_input = QLineEdit() + main_app.dir_input.setPlaceholderText("Select folder where downloads will be saved") + main_app.dir_button = QPushButton("Browse...") + main_app.dir_button.clicked.connect(main_app.browse_directory) + dir_layout.addWidget(main_app.dir_input, 1) + dir_layout.addWidget(main_app.dir_button) + left_layout.addLayout(dir_layout) + + # --- Filters and Custom Folder Container --- + main_app.filters_and_custom_folder_container_widget = QWidget() + filters_and_custom_folder_layout = QHBoxLayout(main_app.filters_and_custom_folder_container_widget) + filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0) + filters_and_custom_folder_layout.setSpacing(10) + main_app.character_filter_widget = QWidget() + character_filter_v_layout = QVBoxLayout(main_app.character_filter_widget) + character_filter_v_layout.setContentsMargins(0, 0, 0, 0) + character_filter_v_layout.setSpacing(2) + main_app.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):") + character_filter_v_layout.addWidget(main_app.character_label) + char_input_and_button_layout = QHBoxLayout() + char_input_and_button_layout.setContentsMargins(0, 0, 0, 0) + char_input_and_button_layout.setSpacing(10) + main_app.character_input = QLineEdit() + main_app.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)") + char_input_and_button_layout.addWidget(main_app.character_input, 3) + main_app.char_filter_scope_toggle_button = QPushButton() + main_app._update_char_filter_scope_button_text() + char_input_and_button_layout.addWidget(main_app.char_filter_scope_toggle_button, 1) + character_filter_v_layout.addLayout(char_input_and_button_layout) + + # --- Custom Folder Widget Definition --- + main_app.custom_folder_widget = QWidget() + custom_folder_v_layout = QVBoxLayout(main_app.custom_folder_widget) + custom_folder_v_layout.setContentsMargins(0, 0, 0, 0) + custom_folder_v_layout.setSpacing(2) + main_app.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):") + main_app.custom_folder_input = QLineEdit() + main_app.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder") + custom_folder_v_layout.addWidget(main_app.custom_folder_label) + custom_folder_v_layout.addWidget(main_app.custom_folder_input) + main_app.custom_folder_widget.setVisible(False) + + filters_and_custom_folder_layout.addWidget(main_app.character_filter_widget, 1) + filters_and_custom_folder_layout.addWidget(main_app.custom_folder_widget, 1) + left_layout.addWidget(main_app.filters_and_custom_folder_container_widget) + + # --- Word Manipulation Container --- + word_manipulation_container_widget = QWidget() + word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget) + word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0) + word_manipulation_outer_layout.setSpacing(15) + skip_words_widget = QWidget() + skip_words_vertical_layout = QVBoxLayout(skip_words_widget) + skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0) + skip_words_vertical_layout.setSpacing(2) + main_app.skip_words_label_widget = QLabel() + skip_words_vertical_layout.addWidget(main_app.skip_words_label_widget) + skip_input_and_button_layout = QHBoxLayout() + skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0) + skip_input_and_button_layout.setSpacing(10) + main_app.skip_words_input = QLineEdit() + main_app.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview") + skip_input_and_button_layout.addWidget(main_app.skip_words_input, 1) + main_app.skip_scope_toggle_button = QPushButton() + main_app._update_skip_scope_button_text() + skip_input_and_button_layout.addWidget(main_app.skip_scope_toggle_button, 0) + skip_words_vertical_layout.addLayout(skip_input_and_button_layout) + word_manipulation_outer_layout.addWidget(skip_words_widget, 7) + remove_words_widget = QWidget() + remove_words_vertical_layout = QVBoxLayout(remove_words_widget) + remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0) + remove_words_vertical_layout.setSpacing(2) + main_app.remove_from_filename_label_widget = QLabel() + remove_words_vertical_layout.addWidget(main_app.remove_from_filename_label_widget) + main_app.remove_from_filename_input = QLineEdit() + main_app.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD") + remove_words_vertical_layout.addWidget(main_app.remove_from_filename_input) + word_manipulation_outer_layout.addWidget(remove_words_widget, 3) + left_layout.addWidget(word_manipulation_container_widget) + + # --- File Filter Layout --- + file_filter_layout = QVBoxLayout() + file_filter_layout.setContentsMargins(0, 10, 0, 0) + file_filter_layout.addWidget(QLabel("Filter Files:")) + radio_button_layout = QHBoxLayout() + radio_button_layout.setSpacing(10) + main_app.radio_group = QButtonGroup(main_app) + main_app.radio_all = QRadioButton("All") + main_app.radio_images = QRadioButton("Images/GIFs") + main_app.radio_videos = QRadioButton("Videos") + main_app.radio_only_archives = QRadioButton("📦 Only Archives") + main_app.radio_only_audio = QRadioButton("🎧 Only Audio") + main_app.radio_only_links = QRadioButton("🔗 Only Links") + main_app.radio_more = QRadioButton("More") + + main_app.radio_all.setChecked(True) + for btn in [main_app.radio_all, main_app.radio_images, main_app.radio_videos, main_app.radio_only_archives, main_app.radio_only_audio, main_app.radio_only_links, main_app.radio_more]: + main_app.radio_group.addButton(btn) + radio_button_layout.addWidget(btn) + main_app.favorite_mode_checkbox = QCheckBox() + main_app.favorite_mode_checkbox.setChecked(False) + radio_button_layout.addWidget(main_app.favorite_mode_checkbox) + radio_button_layout.addStretch(1) + file_filter_layout.addLayout(radio_button_layout) + left_layout.addLayout(file_filter_layout) + + # --- Checkboxes Group --- + checkboxes_group_layout = QVBoxLayout() + checkboxes_group_layout.setSpacing(10) + row1_layout = QHBoxLayout() + row1_layout.setSpacing(10) + main_app.skip_zip_checkbox = QCheckBox("Skip .zip") + main_app.skip_zip_checkbox.setChecked(True) + row1_layout.addWidget(main_app.skip_zip_checkbox) + main_app.skip_rar_checkbox = QCheckBox("Skip .rar") + main_app.skip_rar_checkbox.setChecked(True) + row1_layout.addWidget(main_app.skip_rar_checkbox) + main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") + row1_layout.addWidget(main_app.download_thumbnails_checkbox) + main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images") + main_app.scan_content_images_checkbox.setChecked(main_app.scan_content_images_setting) + row1_layout.addWidget(main_app.scan_content_images_checkbox) + main_app.compress_images_checkbox = QCheckBox("Compress to WebP") + main_app.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).") + row1_layout.addWidget(main_app.compress_images_checkbox) + main_app.keep_duplicates_checkbox = QCheckBox("Keep Duplicates") + main_app.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.") + row1_layout.addWidget(main_app.keep_duplicates_checkbox) + row1_layout.addStretch(1) + checkboxes_group_layout.addLayout(row1_layout) + + # --- Advanced Settings --- + advanced_settings_label = QLabel("⚙️ Advanced Settings:") + checkboxes_group_layout.addWidget(advanced_settings_label) + advanced_row1_layout = QHBoxLayout() + advanced_row1_layout.setSpacing(10) + main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title") + main_app.use_subfolders_checkbox.setChecked(True) + main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders) + advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox) + main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post") + main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders) + advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox) + main_app.date_prefix_checkbox = QCheckBox("Date Prefix") + main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") + advanced_row1_layout.addWidget(main_app.date_prefix_checkbox) + main_app.use_cookie_checkbox = QCheckBox("Use Cookie") + main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting) + main_app.cookie_text_input = QLineEdit() + main_app.cookie_text_input.setPlaceholderText("if no Select cookies.txt)") + main_app.cookie_text_input.setText(main_app.cookie_text_setting) + advanced_row1_layout.addWidget(main_app.use_cookie_checkbox) + advanced_row1_layout.addWidget(main_app.cookie_text_input, 2) + main_app.cookie_browse_button = QPushButton("Browse...") + main_app.cookie_browse_button.setFixedWidth(80 * scale) + advanced_row1_layout.addWidget(main_app.cookie_browse_button) + advanced_row1_layout.addStretch(1) + checkboxes_group_layout.addLayout(advanced_row1_layout) + advanced_row2_layout = QHBoxLayout() + advanced_row2_layout.setSpacing(10) + multithreading_layout = QHBoxLayout() + multithreading_layout.setContentsMargins(0, 0, 0, 0) + main_app.use_multithreading_checkbox = QCheckBox("Use Multithreading") + main_app.use_multithreading_checkbox.setChecked(True) + multithreading_layout.addWidget(main_app.use_multithreading_checkbox) + main_app.thread_count_label = QLabel("Threads:") + multithreading_layout.addWidget(main_app.thread_count_label) + main_app.thread_count_input = QLineEdit("4") + main_app.thread_count_input.setFixedWidth(40 * scale) + main_app.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS)) + multithreading_layout.addWidget(main_app.thread_count_input) + advanced_row2_layout.addLayout(multithreading_layout) + main_app.external_links_checkbox = QCheckBox("Show External Links in Log") + advanced_row2_layout.addWidget(main_app.external_links_checkbox) + main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode") + advanced_row2_layout.addWidget(main_app.manga_mode_checkbox) + advanced_row2_layout.addStretch(1) + checkboxes_group_layout.addLayout(advanced_row2_layout) + left_layout.addLayout(checkboxes_group_layout) + + # --- Action Buttons --- + main_app.standard_action_buttons_widget = QWidget() + btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget) + btn_layout.setContentsMargins(0, 10, 0, 0) + btn_layout.setSpacing(10) + main_app.download_btn = QPushButton("⬇️ Start Download") + main_app.download_btn.setStyleSheet("font-weight: bold;") + main_app.download_btn.clicked.connect(main_app.start_download) + main_app.pause_btn = QPushButton("⏸️ Pause Download") + main_app.pause_btn.setEnabled(False) + main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action) + main_app.cancel_btn = QPushButton("❌ Cancel & Reset UI") + main_app.cancel_btn.setEnabled(False) + main_app.cancel_btn.clicked.connect(main_app.cancel_download_button_action) + main_app.error_btn = QPushButton("Error") + main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.") + main_app.error_btn.setEnabled(True) + btn_layout.addWidget(main_app.download_btn) + btn_layout.addWidget(main_app.pause_btn) + btn_layout.addWidget(main_app.cancel_btn) + btn_layout.addWidget(main_app.error_btn) + main_app.favorite_action_buttons_widget = QWidget() + favorite_buttons_layout = QHBoxLayout(main_app.favorite_action_buttons_widget) + main_app.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists") + main_app.favorite_mode_posts_button = QPushButton("📄 Favorite Posts") + main_app.favorite_scope_toggle_button = QPushButton() + favorite_buttons_layout.addWidget(main_app.favorite_mode_artists_button) + favorite_buttons_layout.addWidget(main_app.favorite_mode_posts_button) + favorite_buttons_layout.addWidget(main_app.favorite_scope_toggle_button) + main_app.bottom_action_buttons_stack = QStackedWidget() + main_app.bottom_action_buttons_stack.addWidget(main_app.standard_action_buttons_widget) + main_app.bottom_action_buttons_stack.addWidget(main_app.favorite_action_buttons_widget) + left_layout.addWidget(main_app.bottom_action_buttons_stack) + left_layout.addSpacing(10) + + # --- Known Names Layout --- + known_chars_label_layout = QHBoxLayout() + known_chars_label_layout.setSpacing(10) + main_app.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") + known_chars_label_layout.addWidget(main_app.known_chars_label) + main_app.open_known_txt_button = QPushButton("Open Known.txt") + main_app.open_known_txt_button.setFixedWidth(120 * scale) + known_chars_label_layout.addWidget(main_app.open_known_txt_button) + main_app.character_search_input = QLineEdit() + main_app.character_search_input.setPlaceholderText("Search characters...") + known_chars_label_layout.addWidget(main_app.character_search_input, 1) + left_layout.addLayout(known_chars_label_layout) + main_app.character_list = QListWidget() + main_app.character_list.setSelectionMode(QListWidget.ExtendedSelection) + left_layout.addWidget(main_app.character_list, 1) + char_manage_layout = QHBoxLayout() + char_manage_layout.setSpacing(10) + main_app.new_char_input = QLineEdit() + main_app.new_char_input.setPlaceholderText("Add new show/character name") + main_app.add_char_button = QPushButton("➕ Add") + main_app.add_to_filter_button = QPushButton("⤵️ Add to Filter") + main_app.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.") + main_app.delete_char_button = QPushButton("🗑️ Delete Selected") + main_app.delete_char_button.setToolTip("Delete the selected name(s)...") + main_app.add_char_button.clicked.connect(main_app._handle_ui_add_new_character) + main_app.new_char_input.returnPressed.connect(main_app.add_char_button.click) + main_app.delete_char_button.clicked.connect(main_app.delete_selected_character) + char_manage_layout.addWidget(main_app.new_char_input, 2) + char_manage_layout.addWidget(main_app.add_char_button, 0) + main_app.known_names_help_button = QPushButton("?") + main_app.known_names_help_button.setFixedWidth(45 * scale) + main_app.known_names_help_button.clicked.connect(main_app._show_feature_guide) + main_app.history_button = QPushButton("📜") + main_app.history_button.setFixedWidth(45 * scale) + main_app.history_button.setToolTip(main_app._tr("history_button_tooltip_text", "View download history")) + main_app.future_settings_button = QPushButton("⚙️") + main_app.future_settings_button.setFixedWidth(45 * scale) + main_app.future_settings_button.clicked.connect(main_app._show_future_settings_dialog) + char_manage_layout.addWidget(main_app.add_to_filter_button, 1) + char_manage_layout.addWidget(main_app.delete_char_button, 1) + char_manage_layout.addWidget(main_app.known_names_help_button, 0) + char_manage_layout.addWidget(main_app.history_button, 0) + char_manage_layout.addWidget(main_app.future_settings_button, 0) + left_layout.addLayout(char_manage_layout) + left_layout.addStretch(0) + + # --- Right Panel (Logs) --- + right_panel_widget.setLayout(right_layout) + log_title_layout = QHBoxLayout() + main_app.progress_log_label = QLabel("📜 Progress Log:") + log_title_layout.addWidget(main_app.progress_log_label) + log_title_layout.addStretch(1) + main_app.link_search_input = QLineEdit() + main_app.link_search_input.setPlaceholderText("Search Links...") + main_app.link_search_input.setVisible(False) + log_title_layout.addWidget(main_app.link_search_input) + main_app.link_search_button = QPushButton("🔍") + main_app.link_search_button.setVisible(False) + main_app.link_search_button.setFixedWidth(30 * scale) + log_title_layout.addWidget(main_app.link_search_button) + main_app.manga_rename_toggle_button = QPushButton() + main_app.manga_rename_toggle_button.setVisible(False) + main_app.manga_rename_toggle_button.setFixedWidth(140 * scale) + main_app._update_manga_filename_style_button_text() + log_title_layout.addWidget(main_app.manga_rename_toggle_button) + main_app.manga_date_prefix_input = QLineEdit() + main_app.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames") + main_app.manga_date_prefix_input.setVisible(False) + log_title_layout.addWidget(main_app.manga_date_prefix_input) + main_app.multipart_toggle_button = QPushButton() + main_app.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.") + main_app.multipart_toggle_button.setFixedWidth(130 * scale) + main_app._update_multipart_toggle_button_text() + log_title_layout.addWidget(main_app.multipart_toggle_button) + main_app.EYE_ICON = "\U0001F441" + main_app.CLOSED_EYE_ICON = "\U0001F648" + main_app.log_verbosity_toggle_button = QPushButton(main_app.EYE_ICON) + main_app.log_verbosity_toggle_button.setFixedWidth(45 * scale) + main_app.log_verbosity_toggle_button.setStyleSheet(f"font-size: {11 * scale}pt; padding: {4 * scale}px {2 * scale}px;") + log_title_layout.addWidget(main_app.log_verbosity_toggle_button) + main_app.reset_button = QPushButton("🔄 Reset") + main_app.reset_button.setFixedWidth(80 * scale) + log_title_layout.addWidget(main_app.reset_button) + right_layout.addLayout(log_title_layout) + main_app.log_splitter = QSplitter(Qt.Vertical) + main_app.log_view_stack = QStackedWidget() + main_app.main_log_output = QTextEdit() + main_app.main_log_output.setReadOnly(True) + main_app.main_log_output.setLineWrapMode(QTextEdit.NoWrap) + main_app.log_view_stack.addWidget(main_app.main_log_output) + main_app.missed_character_log_output = QTextEdit() + main_app.missed_character_log_output.setReadOnly(True) + main_app.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap) + main_app.log_view_stack.addWidget(main_app.missed_character_log_output) + main_app.external_log_output = QTextEdit() + main_app.external_log_output.setReadOnly(True) + main_app.external_log_output.setLineWrapMode(QTextEdit.NoWrap) + main_app.external_log_output.hide() + main_app.log_splitter.addWidget(main_app.log_view_stack) + main_app.log_splitter.addWidget(main_app.external_log_output) + main_app.log_splitter.setSizes([main_app.height(), 0]) + right_layout.addWidget(main_app.log_splitter, 1) + export_button_layout = QHBoxLayout() + export_button_layout.addStretch(1) + main_app.export_links_button = QPushButton(main_app._tr("export_links_button_text", "Export Links")) + main_app.export_links_button.setFixedWidth(100 * scale) + main_app.export_links_button.setEnabled(False) + main_app.export_links_button.setVisible(False) + export_button_layout.addWidget(main_app.export_links_button) + main_app.download_extracted_links_button = QPushButton(main_app._tr("download_extracted_links_button_text", "Download")) + main_app.download_extracted_links_button.setFixedWidth(100 * scale) + main_app.download_extracted_links_button.setEnabled(False) + main_app.download_extracted_links_button.setVisible(False) + export_button_layout.addWidget(main_app.download_extracted_links_button) + main_app.log_display_mode_toggle_button = QPushButton() + main_app.log_display_mode_toggle_button.setFixedWidth(120 * scale) + main_app.log_display_mode_toggle_button.setVisible(False) + export_button_layout.addWidget(main_app.log_display_mode_toggle_button) + right_layout.addLayout(export_button_layout) + main_app.progress_label = QLabel("Progress: Idle") + main_app.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;") + right_layout.addWidget(main_app.progress_label) + main_app.file_progress_label = QLabel("") + main_app.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.") + main_app.file_progress_label.setWordWrap(True) + main_app.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;") + right_layout.addWidget(main_app.file_progress_label) + + # --- Final Assembly --- + main_app.main_splitter.addWidget(left_scroll_area) + main_app.main_splitter.addWidget(right_panel_widget) + main_app.main_splitter.setStretchFactor(0, 7) + main_app.main_splitter.setStretchFactor(1, 3) + top_level_layout = QHBoxLayout(main_app) + top_level_layout.setContentsMargins(0, 0, 0, 0) + top_level_layout.addWidget(main_app.main_splitter) + + # --- Initial UI State Updates --- + main_app.update_ui_for_subfolders(main_app.use_subfolders_checkbox.isChecked()) + main_app.update_external_links_setting(main_app.external_links_checkbox.isChecked()) + main_app.update_multithreading_label(main_app.thread_count_input.text()) + main_app.update_page_range_enabled_state() + if main_app.manga_mode_checkbox: + main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked()) + if hasattr(main_app, 'link_input'): + main_app.link_input.textChanged.connect(lambda: main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked() if main_app.manga_mode_checkbox else False)) + main_app._load_creator_name_cache_from_json() + main_app.load_known_names_from_util() + main_app._update_cookie_input_visibility(main_app.use_cookie_checkbox.isChecked() if hasattr(main_app, 'use_cookie_checkbox') else False) + main_app._handle_multithreading_toggle(main_app.use_multithreading_checkbox.isChecked()) + if hasattr(main_app, 'radio_group') and main_app.radio_group.checkedButton(): + main_app._handle_filter_mode_change(main_app.radio_group.checkedButton(), True) + main_app.radio_group.buttonToggled.connect(main_app._handle_more_options_toggled) + + main_app._update_manga_filename_style_button_text() + main_app._update_skip_scope_button_text() + main_app._update_char_filter_scope_button_text() + main_app._update_multithreading_for_date_mode() + if hasattr(main_app, 'download_thumbnails_checkbox'): + main_app._handle_thumbnail_mode_change(main_app.download_thumbnails_checkbox.isChecked()) + if hasattr(main_app, 'favorite_mode_checkbox'): + main_app._handle_favorite_mode_toggle(False) + +def get_dark_theme(scale=1): + """ + Generates the stylesheet for the dark theme, scaled by the given factor. + """ + # Define base sizes + font_size_base = 10 + font_size_small_base = 9.5 + padding_base = 5 + padding_small = 4 + button_h_padding_base = 12 + indicator_size_base = 14 + + # Apply scaling + font_size = font_size_base * scale + font_size_small = font_size_small_base * scale + line_edit_padding = padding_base * scale + button_padding_v = padding_base * scale + button_padding_h = button_h_padding_base * scale + tooltip_padding = padding_small * scale + + return f""" + QWidget {{ background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: {font_size}pt; }} + QLineEdit, QListWidget {{ background-color: #3C3F41; border: 1px solid #5A5A5A; padding: {line_edit_padding}px; color: #F0F0F0; border-radius: 4px; }} + QTextEdit {{ background-color: #3C3F41; border: 1px solid #5A5A5A; padding: {line_edit_padding}px; + color: #F0F0F0; border-radius: 4px; + font-family: Consolas, Courier New, monospace; font-size: {font_size_small}pt; }} + QPushButton {{ background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: {button_padding_v}px {button_padding_h}px; border-radius: 4px; }} + QPushButton:hover {{ background-color: #656565; border: 1px solid #7A7A7A; }} + QPushButton:pressed {{ background-color: #4A4A4A; }} + QPushButton:disabled {{ background-color: #404040; color: #888; border-color: #555; }} + QLabel {{ font-weight: bold; padding-top: {4 * scale}px; padding-bottom: {2 * scale}px; color: #C0C0C0; }} + QRadioButton, QCheckBox {{ spacing: {5 * scale}px; color: #E0E0E0; padding-top: {4 * scale}px; padding-bottom: {4 * scale}px; }} + QRadioButton::indicator, QCheckBox::indicator {{ width: {indicator_size_base * scale}px; height: {indicator_size_base * scale}px; }} + QListWidget {{ alternate-background-color: #353535; border: 1px solid #5A5A5A; }} + QListWidget::item:selected {{ background-color: #007ACC; color: #FFFFFF; }} + QToolTip {{ background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: {tooltip_padding}px; border-radius: 3px; }} + QSplitter::handle {{ background-color: #5A5A5A; }} + QSplitter::handle:horizontal {{ width: {5 * scale}px; }} + QSplitter::handle:vertical {{ height: {5 * scale}px; }} + QFrame[frameShape="4"], QFrame[frameShape="5"] {{ + border: 1px solid #4A4A4A; + border-radius: 3px; + }} + """ +def apply_theme_to_app(main_app, theme_name, initial_load=False): + """ + Applies the selected theme and scaling to the main application window. + """ + main_app.current_theme = theme_name + if not initial_load: + main_app.settings.setValue(THEME_KEY, theme_name) + main_app.settings.sync() + + if theme_name == "dark": + scale = getattr(main_app, 'scale_factor', 1) + main_app.setStyleSheet(get_dark_theme(scale)) + if not initial_load: + main_app.log_signal.emit("🎨 Switched to Dark Mode.") + else: + main_app.setStyleSheet("") + if not initial_load: + main_app.log_signal.emit("🎨 Switched to Light Mode.") + main_app.update()