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
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'
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