diff --git a/main.py b/main.py index f637bd9..1a682e2 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ import requests import re import threading import queue -import hashlib # Import hashlib for hashing +import hashlib from concurrent.futures import ThreadPoolExecutor, Future, CancelledError from PyQt5.QtGui import QIcon @@ -20,42 +20,25 @@ try: from PIL import Image except ImportError: print("ERROR: Pillow library not found. Please install it: pip install Pillow") - Image = None # Set to None to handle gracefully later - -from PyQt5.QtGui import QIcon - -app = QApplication(sys.argv) -app.setWindowIcon(QIcon("Kemono.ico")) # Taskbar + window icon - -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("My App") + Image = None from io import BytesIO -fastapi_app = None # Set to None directly +fastapi_app = None KNOWN_NAMES = [] def clean_folder_name(name): - """Removes invalid characters for folder names and replaces spaces with underscores.""" - if not isinstance(name, str): name = str(name) # Ensure input is string + if not isinstance(name, str): name = str(name) cleaned = re.sub(r'[^\w\s\-\_]', '', name) return cleaned.strip().replace(' ', '_') def clean_filename(name): - """Removes invalid characters for filenames and replaces spaces with underscores.""" - if not isinstance(name, str): name = str(name) # Ensure input is string + if not isinstance(name, str): name = str(name) cleaned = re.sub(r'[^\w\s\-\_\.]', '', name) return cleaned.strip().replace(' ', '_') - def extract_folder_name_from_title(title, unwanted_keywords): - """ - Tries to find a suitable folder name from the title's first valid token. - Falls back to 'Uncategorized' if no suitable name is found. - """ if not title: return 'Uncategorized' title_lower = title.lower() tokens = title_lower.split() @@ -63,31 +46,25 @@ def extract_folder_name_from_title(title, unwanted_keywords): clean_token = clean_folder_name(token) if clean_token and clean_token not in unwanted_keywords: return clean_token - return 'Uncategorized' # Fallback if no suitable token found - + return 'Uncategorized' def match_folders_from_title(title, known_names, unwanted_keywords): - """ - Matches known names (phrases/keywords) within the cleaned title. - Returns a list of *cleaned* known names found. - """ if not title: return [] cleaned_title = clean_folder_name(title.lower()) matched_cleaned_names = set() for name in known_names: cleaned_name_for_match = clean_folder_name(name.lower()) - if not cleaned_name_for_match: continue # Skip empty known names + if not cleaned_name_for_match: continue if cleaned_name_for_match in cleaned_title: if cleaned_name_for_match not in unwanted_keywords: matched_cleaned_names.add(cleaned_name_for_match) return list(matched_cleaned_names) - def is_image(filename): if not filename: return False - return filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif')) # Added gif + return filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif')) def is_video(filename): if not filename: return False @@ -101,16 +78,11 @@ def is_rar(filename): if not filename: return False return filename.lower().endswith('.rar') - def is_post_url(url): if not isinstance(url, str): return False return '/post/' in urlparse(url).path def extract_post_info(url_string): - """ - Extracts service, user_id, and post_id from Kemono/Coomer URLs. - Returns (service, user_id, post_id) or (None, None, None). - """ service, user_id, post_id = None, None, None if not isinstance(url_string, str) or not url_string.strip(): return None, None, None @@ -119,10 +91,10 @@ def extract_post_info(url_string): parsed_url = urlparse(url_string.strip()) domain = parsed_url.netloc.lower() path_parts = [part for part in parsed_url.path.strip('/').split('/') if part] - is_kemono = 'kemono.su' in domain or 'kemono.party' in domain # Added kemono.party - is_coomer = 'coomer.su' in domain or 'coomer.party' in domain # Added coomer.party + is_kemono = 'kemono.su' in domain or 'kemono.party' in domain + is_coomer = 'coomer.su' in domain or 'coomer.party' in domain if not (is_kemono or is_coomer): - return None, None, None # Unknown domain + return None, None, None if len(path_parts) >= 3 and path_parts[1].lower() == 'user': service = path_parts[0] user_id = path_parts[2] @@ -136,22 +108,20 @@ def extract_post_info(url_string): post_id = path_parts[6] return service, user_id, post_id - except ValueError: # Handle potential errors during URL parsing + except ValueError: print(f"Debug: ValueError parsing URL '{url_string}'") return None, None, None - except Exception as e: # Catch other unexpected errors + except Exception as e: print(f"Debug: Exception during extract_post_info for URL '{url_string}': {e}") return None, None, None return None, None, None - def fetch_posts_paginated(api_url_base, headers, offset, logger): - """Fetches a single page of posts from the creator API.""" paginated_url = f'{api_url_base}?o={offset}' logger(f" Fetching: {paginated_url}") try: - response = requests.get(paginated_url, headers=headers, timeout=45) # Increased timeout - response.raise_for_status() # Check for 4xx/5xx errors + response = requests.get(paginated_url, headers=headers, timeout=45) + response.raise_for_status() if 'application/json' not in response.headers.get('Content-Type', ''): raise RuntimeError(f"Unexpected content type received: {response.headers.get('Content-Type')}. Body: {response.text[:200]}") return response.json() @@ -162,31 +132,26 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger): if e.response is not None: err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})" raise RuntimeError(err_msg) - except ValueError as e: # JSONDecodeError inherits from ValueError + except ValueError as e: raise RuntimeError(f"Error decoding JSON response for offset {offset}: {e}. Body: {response.text[:200]}") except Exception as e: raise RuntimeError(f"Unexpected error processing page offset {offset}: {e}") - def download_from_api(api_url_input, logger=print): - """ - Generator function yielding batches of posts from the API. - Handles pagination and single post fetching. - """ headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'} service, user_id, target_post_id = extract_post_info(api_url_input) if not service or not user_id: logger(f"❌ Invalid or unrecognized URL: {api_url_input}. Cannot fetch.") - return # Stop generator + return parsed_input = urlparse(api_url_input) - api_domain = parsed_input.netloc if ('kemono.su' in parsed_input.netloc.lower() or 'coomer.su' in parsed_input.netloc.lower() or 'kemono.party' in parsed_input.netloc.lower() or 'coomer.party' in parsed_input.netloc.lower()) else "kemono.su" # Added .party domains + api_domain = parsed_input.netloc if ('kemono.su' in parsed_input.netloc.lower() or 'coomer.su' in parsed_input.netloc.lower() or 'kemono.party' in parsed_input.netloc.lower() or 'coomer.party' in parsed_input.netloc.lower()) else "kemono.su" api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}" offset = 0 page = 1 - processed_target_post = False # Flag to stop if target is found + processed_target_post = False while True: if target_post_id and processed_target_post: @@ -198,7 +163,7 @@ def download_from_api(api_url_input, logger=print): posts_batch = fetch_posts_paginated(api_base_url, headers, offset, logger) if not isinstance(posts_batch, list): logger(f"❌ API Error: Expected a list of posts, got {type(posts_batch)}. Response: {str(posts_batch)[:200]}") - break # Stop if response format is wrong + break except RuntimeError as e: logger(f"❌ {e}") logger(" Aborting pagination due to error.") @@ -207,12 +172,12 @@ def download_from_api(api_url_input, logger=print): logger(f"❌ Unexpected error during fetch loop: {e}") break - if not posts_batch: # Empty list means end of posts + if not posts_batch: if page == 1 and not target_post_id: logger("😕 No posts found for this creator.") elif not target_post_id: logger("✅ Reached end of posts.") - break # Stop pagination + break logger(f"đŸ“Ļ Found {len(posts_batch)} posts on page {page}.") @@ -221,8 +186,8 @@ def download_from_api(api_url_input, logger=print): if matching_post: logger(f"đŸŽ¯ Found target post {target_post_id} on page {page}.") - yield [matching_post] # Yield only the target post - processed_target_post = True # Set flag to stop after this + yield [matching_post] + processed_target_post = True else: logger(f" Target post {target_post_id} not found on this page.") pass @@ -232,23 +197,22 @@ def download_from_api(api_url_input, logger=print): page_size = 50 offset += page_size page += 1 - time.sleep(0.6) # Slightly increased delay between page fetches + time.sleep(0.6) if target_post_id and not processed_target_post: logger(f"❌ Target post ID {target_post_id} was not found for this creator.") + class PostProcessorSignals(QObject): - """Defines signals emitted by worker threads.""" progress_signal = pyqtSignal(str) - file_download_status_signal = pyqtSignal(bool) # True=start, False=end + file_download_status_signal = pyqtSignal(bool) class PostProcessorWorker: - """Processes a single post within a ThreadPoolExecutor.""" def __init__(self, post_data, download_root, known_names, filter_character, unwanted_keywords, filter_mode, skip_zip, skip_rar, use_subfolders, target_post_id_from_initial_url, custom_folder_name, compress_images, download_thumbnails, service, user_id, api_url_input, cancellation_event, signals, downloaded_files, downloaded_file_hashes, downloaded_files_lock, downloaded_file_hashes_lock, - skip_words_list=None): # ADDED skip_words_list + skip_words_list=None): self.post = post_data self.download_root = download_root self.known_names = known_names @@ -264,49 +228,45 @@ class PostProcessorWorker: self.download_thumbnails = download_thumbnails self.service = service self.user_id = user_id - self.api_url_input = api_url_input # Needed for domain/URL construction - self.cancellation_event = cancellation_event # Shared threading.Event - self.signals = signals # Shared PostProcessorSignals instance - self.skip_current_file_flag = threading.Event() # Event for skipping + self.api_url_input = api_url_input + self.cancellation_event = cancellation_event + self.signals = signals + self.skip_current_file_flag = threading.Event() self.is_downloading_file = False self.current_download_path = None - self.downloaded_files = downloaded_files # Shared set (filenames) - self.downloaded_file_hashes = downloaded_file_hashes # Shared set (hashes) # ADDED - self.downloaded_files_lock = downloaded_files_lock # Use passed lock - self.downloaded_file_hashes_lock = downloaded_file_hashes_lock # Use passed lock # ADDED - self.skip_words_list = skip_words_list if skip_words_list is not None else [] # ADDED + self.downloaded_files = downloaded_files + self.downloaded_file_hashes = downloaded_file_hashes + self.downloaded_files_lock = downloaded_files_lock + self.downloaded_file_hashes_lock = downloaded_file_hashes_lock + self.skip_words_list = skip_words_list if skip_words_list is not None else [] if self.compress_images and Image is None: self.logger("âš ī¸ Image compression enabled, but Pillow library is not loaded. Disabling compression.") self.compress_images = False def logger(self, message): - """Emit progress messages safely via signals.""" if self.signals and hasattr(self.signals, 'progress_signal'): self.signals.progress_signal.emit(message) else: - print(f"(Worker Log): {message}") # Fallback + print(f"(Worker Log): {message}") def check_cancel(self): - """Checks the shared cancellation event.""" is_cancelled = self.cancellation_event.is_set() return is_cancelled def skip_file(self): - """Sets the skip flag (not typically called directly on worker).""" pass def process(self): - """Processes the single post assigned to this worker. Returns (downloaded, skipped).""" if self.check_cancel(): return 0, 0 total_downloaded_post = 0 total_skipped_post = 0 headers = {'User-Agent': 'Mozilla/5.0', 'Referer': f'https://{urlparse(self.api_url_input).netloc}/'} url_pattern = re.compile(r'https?://[^\s<>"]+|www\.[^\s<>"]+') - LARGE_THUMBNAIL_THRESHOLD = 1 * 1024 * 1024 # 1MB + LARGE_THUMBNAIL_THRESHOLD = 1 * 1024 * 1024 post = self.post - api_title = post.get('title', '') # Default to empty string + api_title = post.get('title', '') title = api_title if api_title else 'untitled_post' post_id = post.get('id', 'unknown_id') post_file_info = post.get('file') @@ -320,7 +280,7 @@ class PostProcessorWorker: for skip_word in self.skip_words_list: if skip_word.lower() in title_lower: self.logger(f" -> Skip Post (Title): Post {post_id} title ('{title[:30]}...') contains skip word '{skip_word}'. Skipping entire post.") - return 0, 1 # 0 downloaded, 1 skipped (the whole post) + return 0, 1 if not isinstance(attachments, list): @@ -334,7 +294,7 @@ class PostProcessorWorker: valid_folder_paths = [folder_path_full] folder_decision_reason = f"Using custom folder for target post: '{self.custom_folder_name}'" if not valid_folder_paths and self.use_subfolders: - folder_names_for_post = [] # Cleaned folder names derived for this post + folder_names_for_post = [] if self.filter_character: clean_char_filter = clean_folder_name(self.filter_character.lower()) matched_names_in_title = match_folders_from_title(title, self.known_names, self.unwanted_keywords) @@ -344,11 +304,11 @@ class PostProcessorWorker: folder_decision_reason = f"Character filter '{self.filter_character}' matched title. Using folder '{clean_char_filter}'." else: self.logger(f" -> Filter Skip Post {post_id}: Character filter '{self.filter_character}' not found in title matches ({matched_names_in_title}).") - return 0, 1 # 0 downloaded, 1 skipped (the whole post) + return 0, 1 else: matched_folders = match_folders_from_title(title, self.known_names, self.unwanted_keywords) if matched_folders: - folder_names_for_post = matched_folders # Use all matched known names as folders + folder_names_for_post = matched_folders folder_decision_reason = f"Found known name(s) in title: {matched_folders}" else: extracted_folder = extract_folder_name_from_title(title, self.unwanted_keywords) @@ -358,22 +318,22 @@ class PostProcessorWorker: folder_path_full = os.path.join(self.download_root, folder_name) valid_folder_paths.append(folder_path_full) if not valid_folder_paths: - valid_folder_paths = [self.download_root] # Save directly to root - if not folder_decision_reason: # Add reason if not already set + valid_folder_paths = [self.download_root] + if not folder_decision_reason: folder_decision_reason = "Subfolders disabled or no specific folder determined. Using root download directory." self.logger(f" Folder Decision: {folder_decision_reason}") if not valid_folder_paths: self.logger(f" ERROR: No valid folder paths determined for post {post_id}. Skipping.") - return 0, 1 # Skip post + return 0, 1 if post_content: try: found_links = re.findall(r'href=["\'](https?://[^"\']+)["\']', post_content) if found_links: self.logger(f"🔗 Links found in post content:") - unique_links = sorted(list(set(found_links))) # Remove duplicates - for link in unique_links[:10]: # Log max 10 links + unique_links = sorted(list(set(found_links))) + for link in unique_links[:10]: if not any(x in link for x in ['.css', '.js', 'javascript:']): self.logger(f" - {link}") if len(unique_links) > 10: @@ -384,12 +344,12 @@ class PostProcessorWorker: api_domain = urlparse(self.api_url_input).netloc if ('kemono.su' in urlparse(self.api_url_input).netloc.lower() or 'coomer.su' in urlparse(self.api_url_input).netloc.lower() or 'kemono.party' in urlparse(self.api_url_input).netloc.lower() or 'coomer.party' in urlparse(self.api_url_input).netloc.lower()) else "kemono.su" if self.download_thumbnails: - self.logger(f" Mode: Attempting to download thumbnail...") # Modified log + self.logger(f" Mode: Attempting to download thumbnail...") self.logger(" Thumbnail download via API is disabled as the local API is not used.") self.logger(f" -> Skipping Post {post_id}: Thumbnail download requested but API is disabled.") - return 0, 1 # 0 downloaded, 1 skipped post + return 0, 1 - else: # Normal file download mode + else: self.logger(f" Mode: Downloading post file/attachments.") if post_file_info and isinstance(post_file_info, dict) and post_file_info.get('path'): main_file_path = post_file_info['path'].lstrip('/') @@ -410,17 +370,17 @@ class PostProcessorWorker: if attach_name: base, ext = os.path.splitext(clean_filename(attach_name)) final_attach_name = f"{post_id}_{attachment_counter}{ext}" - if base and base != f"{post_id}_{attachment_counter}": # Avoid doubling if base is already post_id_index + if base and base != f"{post_id}_{attachment_counter}": final_attach_name = f"{post_id}_{attachment_counter}_{base}{ext}" attach_url = f"https://{api_domain}/data/{attach_path}" files_to_process_for_download.append({ - 'url': attach_url, 'name': final_attach_name, # Use the unique name here + 'url': attach_url, 'name': final_attach_name, '_is_thumbnail': False, '_source': f'attachment_{idx+1}', - '_original_name_for_log': attach_name # Keep original for logging + '_original_name_for_log': attach_name }) - attachment_counter += 1 # Increment counter + attachment_counter += 1 else: self.logger(f" âš ī¸ Skipping attachment {idx+1}: Missing filename (Path: {attach_path})") @@ -430,35 +390,35 @@ class PostProcessorWorker: if not files_to_process_for_download: self.logger(f" No files found to download for post {post_id}.") - return 0, 0 # No files, no action needed, not skipped post + return 0, 0 self.logger(f" Files identified for download: {len(files_to_process_for_download)}") post_download_count = 0 - post_skip_count = 0 # Files skipped within this post + post_skip_count = 0 local_processed_filenames = set() local_filenames_lock = threading.Lock() for file_info in files_to_process_for_download: - if self.check_cancel(): break # Check cancellation before each file + if self.check_cancel(): break if self.skip_current_file_flag.is_set(): original_name_for_log = file_info.get('_original_name_for_log', file_info.get('name', 'unknown_file')) self.logger(f"â­ī¸ File skip requested: {original_name_for_log}") post_skip_count += 1 - self.skip_current_file_flag.clear() # Reset flag + self.skip_current_file_flag.clear() continue file_url = file_info.get('url') - original_filename = file_info.get('name') # This is the constructed unique name if applicable + original_filename = file_info.get('name') is_thumbnail = file_info.get('_is_thumbnail', False) - original_name_for_log = file_info.get('_original_name_for_log', original_filename) # Use original for log if available + original_name_for_log = file_info.get('_original_name_for_log', original_filename) if not file_url or not original_filename: self.logger(f"âš ī¸ Skipping file entry due to missing URL or name: {str(file_info)[:100]}") post_skip_count += 1 continue - cleaned_save_filename = clean_filename(original_filename) # Clean the potentially unique name + cleaned_save_filename = clean_filename(original_filename) if self.skip_words_list: filename_lower = cleaned_save_filename.lower() file_skipped_by_word = False @@ -469,11 +429,11 @@ class PostProcessorWorker: file_skipped_by_word = True break if file_skipped_by_word: - continue # Skip to next file in the post - if not self.download_thumbnails: # This condition will always be true now + continue + if not self.download_thumbnails: file_skipped_by_filter = False is_img = is_image(cleaned_save_filename) - is_vid = is_video(cleaned_save_filename) # Using updated is_video + is_vid = is_video(cleaned_save_filename) is_zip_file = is_zip(cleaned_save_filename) is_rar_file = is_rar(cleaned_save_filename) @@ -492,61 +452,61 @@ class PostProcessorWorker: if file_skipped_by_filter: post_skip_count += 1 - continue # Skip to next file + continue file_downloaded_or_exists = False for folder_path in valid_folder_paths: - if self.check_cancel(): break # Check cancellation before each folder attempt + if self.check_cancel(): break try: os.makedirs(folder_path, exist_ok=True) except OSError as e: self.logger(f"❌ Error ensuring directory exists {folder_path}: {e}. Skipping path.") - continue # Try next folder path if available + continue except Exception as e: self.logger(f"❌ Unexpected error creating dir {folder_path}: {e}. Skipping path.") continue save_path = os.path.join(folder_path, cleaned_save_filename) - folder_basename = os.path.basename(folder_path) # For logging - with local_filenames_lock: # Use local lock for filename set within post + folder_basename = os.path.basename(folder_path) + with local_filenames_lock: if os.path.exists(save_path) and os.path.getsize(save_path) > 0: self.logger(f" -> Exists Skip: '{original_name_for_log}' in '{folder_basename}'") - post_skip_count += 1 # Count exists as skipped for this post's summary + post_skip_count += 1 file_downloaded_or_exists = True with self.downloaded_files_lock: self.downloaded_files.add(cleaned_save_filename) - break # Don't try other folders if it exists in one valid location + break elif cleaned_save_filename in local_processed_filenames: self.logger(f" -> Local Skip: '{original_name_for_log}' in '{folder_basename}' (already processed in this post)") post_skip_count += 1 file_downloaded_or_exists = True with self.downloaded_files_lock: self.downloaded_files.add(cleaned_save_filename) - break # Don't try other folders + break with self.downloaded_files_lock: if cleaned_save_filename in self.downloaded_files: self.logger(f" -> Global Filename Skip: '{original_name_for_log}' in '{folder_basename}' (filename already downloaded globally)") post_skip_count += 1 file_downloaded_or_exists = True - break # Don't try other folders + break try: self.logger(f"âŦ‡ī¸ Downloading '{original_name_for_log}' to '{folder_basename}'...") - self.current_download_path = save_path # Still set the potential path + self.current_download_path = save_path self.is_downloading_file = True - self.signals.file_download_status_signal.emit(True) # Signal START - response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True) # (connect_timeout, read_timeout) - response.raise_for_status() # Check for HTTP errors + self.signals.file_download_status_signal.emit(True) + response = requests.get(file_url, headers=headers, timeout=(15, 300), stream=True) + response.raise_for_status() file_content_bytes = BytesIO() downloaded_size = 0 chunk_count = 0 - md5_hash = hashlib.md5() # Initialize hash object + md5_hash = hashlib.md5() - for chunk in response.iter_content(chunk_size=32 * 1024): # 32KB chunks - if self.check_cancel(): break # Check cancellation frequently + for chunk in response.iter_content(chunk_size=32 * 1024): + if self.check_cancel(): break if self.skip_current_file_flag.is_set(): break - if chunk: # filter out keep-alive new chunks + if chunk: file_content_bytes.write(chunk) - md5_hash.update(chunk) # Update hash with chunk + md5_hash.update(chunk) downloaded_size += len(chunk) chunk_count += 1 if self.check_cancel() or self.skip_current_file_flag.is_set(): @@ -554,23 +514,23 @@ class PostProcessorWorker: if self.skip_current_file_flag.is_set(): post_skip_count += 1 self.skip_current_file_flag.clear() - break # Break from trying other folders - final_save_path = save_path # May change if compressed - current_filename_for_log = cleaned_save_filename # May change - file_content_bytes.seek(0) # Rewind the BytesIO object to the beginning + break + final_save_path = save_path + current_filename_for_log = cleaned_save_filename + file_content_bytes.seek(0) if downloaded_size == 0 and chunk_count > 0: self.logger(f"âš ī¸ Warning: Downloaded 0 bytes despite receiving chunks for {original_name_for_log}. Skipping save.") post_skip_count += 1 - break # Treat as failure for this folder + break if downloaded_size > 0: - calculated_hash = md5_hash.hexdigest() # Get the final hash - with self.downloaded_file_hashes_lock: # Use lock for hash set + calculated_hash = md5_hash.hexdigest() + with self.downloaded_file_hashes_lock: if calculated_hash in self.downloaded_file_hashes: self.logger(f" -> Content Skip: '{original_name_for_log}' (Hash: {calculated_hash}) already downloaded.") post_skip_count += 1 - file_downloaded_or_exists = True # Mark as handled + file_downloaded_or_exists = True with self.downloaded_files_lock: self.downloaded_files.add(cleaned_save_filename) with local_filenames_lock: @@ -580,7 +540,7 @@ class PostProcessorWorker: pass - if not file_downloaded_or_exists: # Only proceed if not skipped by hash check + if not file_downloaded_or_exists: final_bytes_to_save = file_content_bytes is_img_for_compress = is_image(cleaned_save_filename) if is_img_for_compress and not is_thumbnail and self.compress_images and Image and downloaded_size > 1500 * 1024: @@ -592,7 +552,7 @@ class PostProcessorWorker: elif img.mode not in ['RGB', 'RGBA', 'L']: img = img.convert('RGB') compressed_bytes = BytesIO() - img.save(compressed_bytes, format='WebP', quality=75, method=4) # Adjust quality/method + img.save(compressed_bytes, format='WebP', quality=75, method=4) compressed_size = compressed_bytes.getbuffer().nbytes if compressed_size < downloaded_size * 0.90: self.logger(f" Compression success: {compressed_size / 1024:.2f} KB (WebP Q75)") @@ -604,30 +564,30 @@ class PostProcessorWorker: self.logger(f" Updated filename: {current_filename_for_log}") else: self.logger(f" Compression skipped: WebP not significantly smaller ({compressed_size / 1024:.2f} KB).") - file_content_bytes.seek(0) # Rewind original bytes + file_content_bytes.seek(0) final_bytes_to_save = file_content_bytes except Exception as comp_e: self.logger(f"❌ Image compression failed for {original_name_for_log}: {comp_e}. Saving original.") - file_content_bytes.seek(0) # Rewind original + file_content_bytes.seek(0) final_bytes_to_save = file_content_bytes - final_save_path = save_path # Ensure original path + final_save_path = save_path elif is_img_for_compress and not is_thumbnail and self.compress_images: self.logger(f" Skipping compression: Image size ({downloaded_size / 1024:.2f} KB) below threshold.") file_content_bytes.seek(0) final_bytes_to_save = file_content_bytes - elif is_thumbnail and downloaded_size > LARGE_THUMBNAIL_THRESHOLD: # This is_thumbnail check is less relevant now + elif is_thumbnail and downloaded_size > LARGE_THUMBNAIL_THRESHOLD: self.logger(f"âš ī¸ Downloaded thumbnail '{current_filename_for_log}' ({downloaded_size / 1024:.2f} KB) is large.") file_content_bytes.seek(0) final_bytes_to_save = file_content_bytes - else: # Ensure stream is rewound if no compression happened + else: file_content_bytes.seek(0) final_bytes_to_save = file_content_bytes save_file = False - with self.downloaded_files_lock: # Lock for global filename set - with local_filenames_lock: # Lock for local filename set + with self.downloaded_files_lock: + with local_filenames_lock: if os.path.exists(final_save_path) and os.path.getsize(final_save_path) > 0: self.logger(f" -> Exists Skip (pre-write): '{current_filename_for_log}' in '{folder_basename}'") post_skip_count += 1 @@ -641,31 +601,31 @@ class PostProcessorWorker: post_skip_count += 1 file_downloaded_or_exists = True else: - save_file = True # OK to save + save_file = True if save_file: try: with open(final_save_path, 'wb') as f: while True: - chunk = final_bytes_to_save.read(64 * 1024) # 64KB write chunks + chunk = final_bytes_to_save.read(64 * 1024) if not chunk: break f.write(chunk) with self.downloaded_file_hashes_lock: - self.downloaded_file_hashes.add(calculated_hash) # ADD HASH + self.downloaded_file_hashes.add(calculated_hash) with self.downloaded_files_lock: - self.downloaded_files.add(current_filename_for_log) # Add filename + self.downloaded_files.add(current_filename_for_log) with local_filenames_lock: - local_processed_filenames.add(current_filename_for_log) # Add filename locally + local_processed_filenames.add(current_filename_for_log) post_download_count += 1 file_downloaded_or_exists = True self.logger(f"✅ Saved: '{current_filename_for_log}' ({downloaded_size / 1024:.1f} KB, Hash: {calculated_hash[:8]}...) in '{folder_basename}'") - time.sleep(0.05) # Tiny delay after successful save + time.sleep(0.05) except IOError as io_err: self.logger(f"❌ Save Fail: '{current_filename_for_log}' to '{folder_basename}'. Error: {io_err}") - post_skip_count += 1 # Count save failure as skip + post_skip_count += 1 if os.path.exists(final_save_path): try: os.remove(final_save_path) except OSError: pass @@ -676,7 +636,7 @@ class PostProcessorWorker: if os.path.exists(final_save_path): try: os.remove(final_save_path) except OSError: pass - break # Break folder loop on unexpected error + break final_bytes_to_save.close() if file_content_bytes is not final_bytes_to_save: file_content_bytes.close() @@ -689,19 +649,19 @@ class PostProcessorWorker: except IOError as e: self.logger(f"❌ File I/O Error: {original_name_for_log} in '{folder_basename}'. Error: {e}") post_skip_count += 1 - break # Break folder loop + break except Exception as e: self.logger(f"❌ Unexpected Error during download/save for {original_name_for_log}: {e}") import traceback self.logger(f" Traceback: {traceback.format_exc(limit=2)}") post_skip_count += 1 - break # Break folder loop on unexpected error + break finally: self.is_downloading_file = False self.current_download_path = None - self.signals.file_download_status_signal.emit(False) # Signal END - if self.check_cancel(): break # Check cancellation after trying all folders + self.signals.file_download_status_signal.emit(False) + if self.check_cancel(): break if self.skip_current_file_flag.is_set(): self.skip_current_file_flag.clear() if not file_downloaded_or_exists: @@ -717,9 +677,9 @@ class DownloaderApp(QWidget): character_prompt_response_signal = pyqtSignal(bool) log_signal = pyqtSignal(str) add_character_prompt_signal = pyqtSignal(str) - file_download_status_signal = pyqtSignal(bool) # Combined start/end - overall_progress_signal = pyqtSignal(int, int) # total, processed - finished_signal = pyqtSignal(int, int, bool) # downloaded, skipped, cancelled + file_download_status_signal = pyqtSignal(bool) + overall_progress_signal = pyqtSignal(int, int) + finished_signal = pyqtSignal(int, int, bool) def __init__(self): @@ -733,24 +693,23 @@ class DownloaderApp(QWidget): self.processed_posts_count = 0 self.download_counter = 0 self.skip_counter = 0 - self.worker_signals = PostProcessorSignals() # Single instance for workers + self.worker_signals = PostProcessorSignals() self.prompt_mutex = QMutex() self._add_character_response = None - self.downloaded_files = set() # Shared set for tracking downloaded filenames (secondary check) - self.downloaded_files_lock = threading.Lock() # Lock for filenames set - self.downloaded_file_hashes = set() # Shared set for tracking downloaded file hashes (primary check) # ADDED - self.downloaded_file_hashes_lock = threading.Lock() # Lock for hashes set # ADDED - self.load_known_names() # Load KNOWN_NAMES global - self.setWindowTitle("Kemono Downloader v2.3 (Content Dedupe & Skip)") # Updated Title - self.setGeometry(150, 150, 1050, 820) # Adjusted size for new field + self.downloaded_files = set() + self.downloaded_files_lock = threading.Lock() + self.downloaded_file_hashes = set() + self.downloaded_file_hashes_lock = threading.Lock() + self.load_known_names() + self.setWindowTitle("Kemono Downloader v2.3 (Content Dedupe & Skip)") + self.setGeometry(150, 150, 1050, 820) self.setStyleSheet(self.get_dark_theme()) - self.init_ui() # Initialize UI elements + self.init_ui() self._connect_signals() self.log_signal.emit("â„šī¸ Local API server functionality has been removed.") def _connect_signals(self): - """Connect all signals for clarity.""" self.worker_signals.progress_signal.connect(self.log) self.worker_signals.file_download_status_signal.connect(self.update_skip_button_state) self.log_signal.connect(self.log) @@ -758,9 +717,8 @@ class DownloaderApp(QWidget): self.character_prompt_response_signal.connect(self.receive_add_character_result) self.overall_progress_signal.connect(self.update_progress_display) self.finished_signal.connect(self.download_finished) - self.character_search_input.textChanged.connect(self.filter_character_list) # CONNECTED + self.character_search_input.textChanged.connect(self.filter_character_list) def load_known_names(self): - """Loads known names from the config file into the global KNOWN_NAMES list.""" global KNOWN_NAMES loaded_names = [] if os.path.exists(self.config_file): @@ -772,12 +730,12 @@ class DownloaderApp(QWidget): except Exception as e: log_msg = f"❌ Error loading config '{self.config_file}': {e}" QMessageBox.warning(self, "Config Load Error", f"Could not load list from {self.config_file}:\n{e}") - loaded_names = [] # Start empty on error + loaded_names = [] else: log_msg = f"â„šī¸ Config file '{self.config_file}' not found. Starting empty." loaded_names = [] - KNOWN_NAMES = loaded_names # Update global list + KNOWN_NAMES = loaded_names if hasattr(self, 'log_output'): self.log_signal.emit(log_msg) else: @@ -785,7 +743,6 @@ class DownloaderApp(QWidget): def save_known_names(self): - """Saves the current global KNOWN_NAMES list to the config file.""" global KNOWN_NAMES try: unique_sorted_names = sorted(list(set(filter(None, KNOWN_NAMES)))) @@ -806,8 +763,7 @@ class DownloaderApp(QWidget): print(log_msg) QMessageBox.warning(self, "Config Save Error", f"Could not save list to {self.config_file}:\n{e}") def closeEvent(self, event): - """Handles application closing: saves config, checks for running downloads.""" - self.save_known_names() # Save names first + self.save_known_names() should_exit = True is_downloading = (self.download_thread and self.download_thread.isRunning()) or (self.thread_pool is not None) @@ -817,19 +773,18 @@ class DownloaderApp(QWidget): QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.Yes: self.log_signal.emit("âš ī¸ Cancelling active download due to application exit...") - self.cancel_download() # Request cancellation + self.cancel_download() else: should_exit = False self.log_signal.emit("â„šī¸ Application exit cancelled.") - event.ignore() # Prevent closing + event.ignore() return if should_exit: - self.log_signal.emit("â„šī¸ Application closing.") # Removed "Stopping API server..." + self.log_signal.emit("â„šī¸ Application closing.") self.log_signal.emit("👋 Exiting application.") - event.accept() # Allow closing + event.accept() def init_ui(self): - """Sets up all the UI widgets and layouts.""" main_layout = QHBoxLayout() left_layout = QVBoxLayout() right_layout = QVBoxLayout() @@ -845,28 +800,28 @@ class DownloaderApp(QWidget): self.dir_button = QPushButton("Browse...") self.dir_button.clicked.connect(self.browse_directory) dir_layout = QHBoxLayout() - dir_layout.addWidget(self.dir_input, 1) # Input takes more space + dir_layout.addWidget(self.dir_input, 1) dir_layout.addWidget(self.dir_button) left_layout.addLayout(dir_layout) self.custom_folder_widget = QWidget() custom_folder_layout = QVBoxLayout(self.custom_folder_widget) - custom_folder_layout.setContentsMargins(0, 5, 0, 0) # Add top margin + custom_folder_layout.setContentsMargins(0, 5, 0, 0) 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_layout.addWidget(self.custom_folder_label) custom_folder_layout.addWidget(self.custom_folder_input) - self.custom_folder_widget.setVisible(False) # Initially hidden + self.custom_folder_widget.setVisible(False) left_layout.addWidget(self.custom_folder_widget) self.character_filter_widget = QWidget() character_filter_layout = QVBoxLayout(self.character_filter_widget) - character_filter_layout.setContentsMargins(0, 5, 0, 0) # Add top margin + character_filter_layout.setContentsMargins(0, 5, 0, 0) self.character_label = QLabel("đŸŽ¯ Filter by Show/Character Name:") self.character_input = QLineEdit() self.character_input.setPlaceholderText("Only download posts matching this known name in title") character_filter_layout.addWidget(self.character_label) character_filter_layout.addWidget(self.character_input) - self.character_filter_widget.setVisible(True) # Initially visible, controlled by subfolder checkbox + self.character_filter_widget.setVisible(True) left_layout.addWidget(self.character_filter_widget) left_layout.addWidget(QLabel("đŸšĢ Skip Posts/Files with Words (comma-separated):")) self.skip_words_input = QLineEdit() @@ -893,9 +848,9 @@ class DownloaderApp(QWidget): self.use_subfolders_checkbox.toggled.connect(self.update_ui_for_subfolders) options_layout_2.addWidget(self.use_subfolders_checkbox) - self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") # Removed (via API) + self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") self.download_thumbnails_checkbox.setChecked(False) - self.download_thumbnails_checkbox.setToolTip("Thumbnail download functionality is currently limited without the API.") # Updated tooltip + self.download_thumbnails_checkbox.setToolTip("Thumbnail download functionality is currently limited without the API.") options_layout_2.addWidget(self.download_thumbnails_checkbox) options_layout_2.addStretch(1) left_layout.addLayout(options_layout_2) @@ -914,15 +869,15 @@ class DownloaderApp(QWidget): options_layout_3.addStretch(1) left_layout.addLayout(options_layout_3) options_layout_4 = QHBoxLayout() - self.use_multithreading_checkbox = QCheckBox(f"Use Multithreading ({4} Threads)") # Use constant - self.use_multithreading_checkbox.setChecked(True) # Default to on + self.use_multithreading_checkbox = QCheckBox(f"Use Multithreading ({4} Threads)") + self.use_multithreading_checkbox.setChecked(True) self.use_multithreading_checkbox.setToolTip("Speeds up downloads for full creator pages.\nSingle post URLs always use one thread.") options_layout_4.addWidget(self.use_multithreading_checkbox) options_layout_4.addStretch(1) left_layout.addLayout(options_layout_4) btn_layout = QHBoxLayout() self.download_btn = QPushButton("âŦ‡ī¸ Start Download") - self.download_btn.setStyleSheet("padding: 8px 15px; font-weight: bold;") # Make prominent + self.download_btn.setStyleSheet("padding: 8px 15px; font-weight: bold;") self.download_btn.clicked.connect(self.start_download) self.cancel_btn = QPushButton("❌ Cancel") self.cancel_btn.setEnabled(False) @@ -935,20 +890,20 @@ class DownloaderApp(QWidget): btn_layout.addWidget(self.cancel_btn) btn_layout.addWidget(self.skip_file_btn) left_layout.addLayout(btn_layout) - left_layout.addSpacing(10) # Add space before list + left_layout.addSpacing(10) known_chars_label_layout = QHBoxLayout() self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):") - self.character_search_input = QLineEdit() # ADDED search bar - self.character_search_input.setPlaceholderText("Search characters...") # ADDED placeholder - known_chars_label_layout.addWidget(self.known_chars_label, 1) # Label takes more space - known_chars_label_layout.addWidget(self.character_search_input) # ADDED search bar + self.character_search_input = QLineEdit() + self.character_search_input.setPlaceholderText("Search characters...") + known_chars_label_layout.addWidget(self.known_chars_label, 1) + known_chars_label_layout.addWidget(self.character_search_input) - left_layout.addLayout(known_chars_label_layout) # Use the new layout + left_layout.addLayout(known_chars_label_layout) self.character_list = QListWidget() self.character_list.addItems(KNOWN_NAMES) self.character_list.setSelectionMode(QListWidget.ExtendedSelection) - left_layout.addWidget(self.character_list, 1) # Allow list to stretch vertically + left_layout.addWidget(self.character_list, 1) char_manage_layout = QHBoxLayout() self.new_char_input = QLineEdit() self.new_char_input.setPlaceholderText("Add new show/character name") @@ -957,21 +912,21 @@ class DownloaderApp(QWidget): self.add_char_button.clicked.connect(self.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) # Input wider + char_manage_layout.addWidget(self.new_char_input, 2) char_manage_layout.addWidget(self.add_char_button, 1) char_manage_layout.addWidget(self.delete_char_button, 1) left_layout.addLayout(char_manage_layout) right_layout.addWidget(QLabel("📜 Progress Log:")) self.log_output = QTextEdit() self.log_output.setReadOnly(True) - self.log_output.setMinimumWidth(450) # Ensure decent width - self.log_output.setLineWrapMode(QTextEdit.WidgetWidth) # Wrap lines - right_layout.addWidget(self.log_output, 1) # Log area stretches + self.log_output.setMinimumWidth(450) + self.log_output.setLineWrapMode(QTextEdit.WidgetWidth) + right_layout.addWidget(self.log_output, 1) self.progress_label = QLabel("Progress: Idle") self.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;") right_layout.addWidget(self.progress_label) - main_layout.addLayout(left_layout, 5) # Left side takes 5 parts width - main_layout.addLayout(right_layout, 4) # Right side takes 4 parts width + main_layout.addLayout(left_layout, 5) + main_layout.addLayout(right_layout, 4) self.setLayout(main_layout) self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked()) self.update_custom_folder_visibility() @@ -980,20 +935,20 @@ class DownloaderApp(QWidget): def get_dark_theme(self): return """ QWidget { - background-color: #2E2E2E; /* Slightly lighter dark */ - color: #E0E0E0; /* Lighter text */ + background-color: #2E2E2E; + color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; } QLineEdit, QTextEdit, QListWidget { background-color: #3C3F41; - border: 1px solid #5A5A5A; /* Slightly lighter border */ + border: 1px solid #5A5A5A; padding: 5px; - color: #F0F0F0; /* Bright text in inputs */ - border-radius: 4px; /* Slightly rounder corners */ + color: #F0F0F0; + border-radius: 4px; } QTextEdit { - font-family: Consolas, Courier New, monospace; /* Monospace for log */ + font-family: Consolas, Courier New, monospace; font-size: 9.5pt; } QPushButton { @@ -1002,17 +957,17 @@ class DownloaderApp(QWidget): border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; - min-height: 22px; /* Ensure clickable height */ + min-height: 22px; } QPushButton:hover { - background-color: #656565; /* Lighter hover */ + background-color: #656565; border: 1px solid #7A7A7A; } QPushButton:pressed { - background-color: #4A4A4A; /* Darker pressed */ + background-color: #4A4A4A; } QPushButton:disabled { - background-color: #404040; /* More distinct disabled */ + background-color: #404040; color: #888; border-color: #555; } @@ -1020,7 +975,7 @@ class DownloaderApp(QWidget): font-weight: bold; padding-top: 4px; padding-bottom: 2px; - color: #C0C0C0; /* Slightly muted labels */ + color: #C0C0C0; } QRadioButton, QCheckBox { spacing: 5px; @@ -1029,15 +984,15 @@ class DownloaderApp(QWidget): padding-bottom: 4px; } QRadioButton::indicator, QCheckBox::indicator { - width: 14px; /* Slightly larger indicators */ + width: 14px; height: 14px; } QListWidget { - alternate-background-color: #353535; /* Subtle alternating color */ + alternate-background-color: #353535; border: 1px solid #5A5A5A; } QListWidget::item:selected { - background-color: #007ACC; /* Standard blue selection */ + background-color: #007ACC; color: #FFFFFF; } QToolTip { @@ -1055,12 +1010,11 @@ class DownloaderApp(QWidget): self.dir_input.setText(folder) def log(self, message): - """Safely appends messages to the log output widget (called via log_signal).""" try: - safe_message = str(message).replace('\x00', '[NULL]') # Ensure string, sanitize nulls + safe_message = str(message).replace('\x00', '[NULL]') self.log_output.append(safe_message) scrollbar = self.log_output.verticalScrollBar() - if scrollbar.value() >= scrollbar.maximum() - 30: # Threshold + if scrollbar.value() >= scrollbar.maximum() - 30: scrollbar.setValue(scrollbar.maximum()) except Exception as e: print(f"GUI Log Error: {e}") @@ -1075,7 +1029,6 @@ class DownloaderApp(QWidget): return 'all' def add_new_character(self): - """Adds anew name to the known names list and updates UI.""" global KNOWN_NAMES name_to_add = self.new_char_input.text().strip() if not name_to_add: @@ -1089,16 +1042,15 @@ class DownloaderApp(QWidget): KNOWN_NAMES.sort(key=str.lower) self.character_list.clear() self.character_list.addItems(KNOWN_NAMES) - self.filter_character_list(self.character_search_input.text()) # Apply current filter # MODIFIED + self.filter_character_list(self.character_search_input.text()) self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list.") self.new_char_input.clear() - self.save_known_names() # Save changes immediately + self.save_known_names() else: QMessageBox.warning(self, "Duplicate Name", f"The name '{name_to_add}' (or similar) already exists in the list.") def delete_selected_character(self): - """Removes selected names from the known names list.""" global KNOWN_NAMES selected_items = self.character_list.selectedItems() if not selected_items: @@ -1118,16 +1070,15 @@ class DownloaderApp(QWidget): if removed_count > 0: self.log_signal.emit(f"đŸ—‘ī¸ Removed {removed_count} name(s) from the list.") self.character_list.clear() - KNOWN_NAMES.sort(key=str.lower) # Re-sort remaining names + KNOWN_NAMES.sort(key=str.lower) self.character_list.addItems(KNOWN_NAMES) - self.filter_character_list(self.character_search_input.text()) # Apply current filter # MODIFIED - self.save_known_names() # Save changes + self.filter_character_list(self.character_search_input.text()) + self.save_known_names() else: self.log_signal.emit("â„šī¸ No names were removed (selection might have changed?).") def update_custom_folder_visibility(self, url_text=None): - """Shows/hides the custom folder input based on URL and subfolder setting.""" if url_text is None: url_text = self.link_input.text() @@ -1136,17 +1087,15 @@ class DownloaderApp(QWidget): self.custom_folder_widget.setVisible(should_show) if not should_show: - self.custom_folder_input.clear() # Clear input if hiding + self.custom_folder_input.clear() def update_ui_for_subfolders(self, checked): - """Updates related UI elements when 'Separate Folders' checkbox changes.""" self.character_filter_widget.setVisible(checked) self.update_custom_folder_visibility() if not checked: self.character_input.clear() def filter_character_list(self, search_text): - """Filters the character list based on the search text.""" search_text = search_text.lower() for i in range(self.character_list.count()): item = self.character_list.item(i) @@ -1157,20 +1106,18 @@ class DownloaderApp(QWidget): def update_progress_display(self, total_posts, processed_posts): - """Updates the progress label based on processed posts.""" if total_posts > 0: try: percent = (processed_posts / total_posts) * 100 self.progress_label.setText(f"Progress: {processed_posts} / {total_posts} posts ({percent:.1f}%)") except ZeroDivisionError: - self.progress_label.setText(f"Progress: {processed_posts} / {total_posts} posts") # Handle rare case - elif processed_posts > 0: # E.g., single post mode might not set total + self.progress_label.setText(f"Progress: {processed_posts} / {total_posts} posts") + elif processed_posts > 0: self.progress_label.setText(f"Progress: Processing post {processed_posts}...") else: self.progress_label.setText("Progress: Starting...") def start_download(self): - """Validates inputs and starts the download in single or multi-threaded mode.""" is_running = (self.download_thread and self.download_thread.isRunning()) or (self.thread_pool is not None) if is_running: self.log_signal.emit("âš ī¸ Download already in progress.") @@ -1185,7 +1132,7 @@ class DownloaderApp(QWidget): compress_images = self.compress_images_checkbox.isChecked() download_thumbnails = self.download_thumbnails_checkbox.isChecked() use_multithreading = self.use_multithreading_checkbox.isChecked() - num_threads = 4 # Define number of threads + num_threads = 4 raw_skip_words = self.skip_words_input.text().strip() skip_words_list = [] if raw_skip_words: @@ -1214,12 +1161,12 @@ class DownloaderApp(QWidget): QMessageBox.critical(self, "Directory Error", f"Could not create directory:\n{e}") self.log_signal.emit(f"❌ Failed to create directory: {output_dir} - {e}") return - else: # User chose not to create + else: return if compress_images and Image is None: QMessageBox.warning(self, "Dependency Missing", "Image compression requires the Pillow library, but it's not installed.\nPlease run: pip install Pillow\n\nCompression will be disabled for this session.") self.log_signal.emit("❌ Cannot compress images: Pillow library not found.") - compress_images = False # Disable for this run + compress_images = False filter_character = None if use_subfolders and self.character_filter_widget.isVisible(): filter_character = self.character_input.text().strip() or None @@ -1234,7 +1181,7 @@ class DownloaderApp(QWidget): else: QMessageBox.warning(self, "Input Warning", f"Custom folder name '{raw_custom_name}' is invalid and will be ignored.") self.log_signal.emit(f"âš ī¸ Invalid custom folder name ignored: {raw_custom_name}") - if use_subfolders and filter_character and not post_id_from_url: # Only validate filter if for whole creator + if use_subfolders and filter_character and not post_id_from_url: clean_char_filter = clean_folder_name(filter_character.lower()) known_names_lower = {name.lower() for name in KNOWN_NAMES} @@ -1248,28 +1195,28 @@ class DownloaderApp(QWidget): QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes) if reply == QMessageBox.Yes: - self.new_char_input.setText(filter_character) # Pre-fill input for user convenience? No, just add it. - self.add_new_character() # This adds, sorts, saves, updates UI + self.new_char_input.setText(filter_character) + self.add_new_character() if filter_character.lower() not in {name.lower() for name in KNOWN_NAMES}: self.log_signal.emit(f"âš ī¸ Failed to add '{filter_character}' automatically. Please add manually if needed.") else: self.log_signal.emit(f"✅ Added filter '{filter_character}' to list.") elif reply == QMessageBox.No: self.log_signal.emit(f"â„šī¸ Proceeding without adding '{filter_character}'. Posts matching it might not be saved to a specific folder unless name is derived.") - else: # Cancel + else: self.log_signal.emit("❌ Download cancelled by user during filter check.") - return # Abort download + return self.log_output.clear() - self.cancellation_event.clear() # Reset cancellation flag + self.cancellation_event.clear() self.active_futures = [] self.total_posts_to_process = 0 self.processed_posts_count = 0 self.download_counter = 0 self.skip_counter = 0 with self.downloaded_files_lock: - self.downloaded_files.clear() # Clear downloaded files set (filenames) + self.downloaded_files.clear() with self.downloaded_file_hashes_lock: - self.downloaded_file_hashes.clear() # Clear downloaded file hashes set # ADDED + self.downloaded_file_hashes.clear() self.progress_label.setText("Progress: Initializing...") self.log_signal.emit("="*40) @@ -1303,7 +1250,7 @@ class DownloaderApp(QWidget): common_args = { 'api_url': api_url, 'output_dir': output_dir, - 'known_names_copy': list(KNOWN_NAMES), # Pass a copy + 'known_names_copy': list(KNOWN_NAMES), 'filter_character': filter_character, 'filter_mode': filter_mode, 'skip_zip': skip_zip, @@ -1315,9 +1262,9 @@ class DownloaderApp(QWidget): 'user_id': user_id, 'downloaded_files': self.downloaded_files, 'downloaded_files_lock': self.downloaded_files_lock, - 'downloaded_file_hashes': self.downloaded_file_hashes, # ADDED - 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, # ADDED - 'skip_words_list': skip_words_list, # --- NEW: Pass skip words --- + 'downloaded_file_hashes': self.downloaded_file_hashes, + 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, + 'skip_words_list': skip_words_list, } if should_use_multithreading: @@ -1337,25 +1284,24 @@ class DownloaderApp(QWidget): import traceback self.log_signal.emit(traceback.format_exc()) QMessageBox.critical(self, "Start Error", f"Failed to start download task:\n{e}") - self.download_finished(0, 0, False) # Reset UI state + self.download_finished(0, 0, False) def start_single_threaded_download(self, **kwargs): - """Starts the download using the dedicated QThread.""" try: self.download_thread = DownloadThread( - cancellation_event = self.cancellation_event, # Pass the shared event + cancellation_event = self.cancellation_event, **kwargs ) if self.download_thread._init_failed: QMessageBox.critical(self, "Thread Error", "Failed to initialize the download thread.\nCheck the log for details.") - self.download_finished(0, 0, False) # Reset UI + self.download_finished(0, 0, False) return - self.download_thread.progress_signal.connect(self.log_signal) # Use log_signal slot - self.download_thread.add_character_prompt_signal.connect(self.add_character_prompt_signal) # Forward signal - self.download_thread.file_download_status_signal.connect(self.file_download_status_signal) # Forward signal - self.download_thread.finished_signal.connect(self.finished_signal) # Forward signal + self.download_thread.progress_signal.connect(self.log_signal) + self.download_thread.add_character_prompt_signal.connect(self.add_character_prompt_signal) + self.download_thread.file_download_status_signal.connect(self.file_download_status_signal) + self.download_thread.finished_signal.connect(self.finished_signal) self.character_prompt_response_signal.connect(self.download_thread.receive_add_character_result) self.download_thread.start() @@ -1366,17 +1312,16 @@ class DownloaderApp(QWidget): import traceback self.log_signal.emit(traceback.format_exc()) QMessageBox.critical(self, "Thread Start Error", f"Failed to start download thread:\n{e}") - self.download_finished(0, 0, False) # Reset UI state + self.download_finished(0, 0, False) def start_multi_threaded_download(self, **kwargs): - """Starts download using ThreadPoolExecutor and a fetcher thread.""" num_threads = kwargs['num_threads'] self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='Downloader_') self.active_futures = [] self.processed_posts_count = 0 - self.total_posts_to_process = 0 # Updated by fetcher + self.total_posts_to_process = 0 self.download_counter = 0 self.skip_counter = 0 worker_args_template = kwargs.copy() @@ -1392,7 +1337,6 @@ class DownloaderApp(QWidget): def _fetch_and_queue_posts(self, api_url_input, worker_args_template): - """(Runs in fetcher thread) Fetches posts and submits tasks to the pool.""" all_posts = [] fetch_error = False try: @@ -1405,12 +1349,12 @@ class DownloaderApp(QWidget): for posts_batch in post_generator: if self.cancellation_event.is_set(): self.log_signal.emit("âš ī¸ Post fetching cancelled by user.") - fetch_error = True # Treat cancellation during fetch as an error state for cleanup + fetch_error = True break if isinstance(posts_batch, list): all_posts.extend(posts_batch) self.total_posts_to_process = len(all_posts) - if self.total_posts_to_process % 250 == 0: # Log every 250 posts + if self.total_posts_to_process % 250 == 0: self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts...") else: self.log_signal.emit(f"❌ API returned non-list batch: {type(posts_batch)}. Stopping fetch.") @@ -1431,21 +1375,21 @@ class DownloaderApp(QWidget): if self.thread_pool: self.thread_pool.shutdown(wait=False, cancel_futures=True) self.thread_pool = None - return # Stop fetcher thread + return if self.total_posts_to_process == 0: self.log_signal.emit("😕 No posts found or fetched successfully.") - self.finished_signal.emit(0, 0, False) # Signal completion with zero counts + self.finished_signal.emit(0, 0, False) return self.log_signal.emit(f" Submitting {self.total_posts_to_process} post tasks to worker pool...") - self.processed_posts_count = 0 # Reset counter before submitting - self.overall_progress_signal.emit(self.total_posts_to_process, 0) # Update progress display + self.processed_posts_count = 0 + self.overall_progress_signal.emit(self.total_posts_to_process, 0) common_worker_args = { - 'download_root': worker_args_template['output_dir'], # **FIXED HERE** + 'download_root': worker_args_template['output_dir'], 'known_names': worker_args_template['known_names_copy'], 'filter_character': worker_args_template['filter_character'], - 'unwanted_keywords': {'spicy', 'hd', 'nsfw', '4k', 'preview'}, # Define unwanted keywords here + 'unwanted_keywords': {'spicy', 'hd', 'nsfw', '4k', 'preview'}, 'filter_mode': worker_args_template['filter_mode'], 'skip_zip': worker_args_template['skip_zip'], 'skip_rar': worker_args_template['skip_rar'], @@ -1456,36 +1400,36 @@ class DownloaderApp(QWidget): 'download_thumbnails': worker_args_template['download_thumbnails'], 'service': worker_args_template['service'], 'user_id': worker_args_template['user_id'], - 'api_url_input': worker_args_template['api_url'], # Pass original URL + 'api_url_input': worker_args_template['api_url'], 'cancellation_event': self.cancellation_event, - 'signals': self.worker_signals, # Pass the shared signals object + 'signals': self.worker_signals, 'downloaded_files': self.downloaded_files, 'downloaded_files_lock': self.downloaded_files_lock, - 'downloaded_file_hashes': self.downloaded_file_hashes, # ADDED - 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, # ADDED - 'skip_words_list': worker_args_template['skip_words_list'], # --- NEW: Pass skip words --- + 'downloaded_file_hashes': self.downloaded_file_hashes, + 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, + 'skip_words_list': worker_args_template['skip_words_list'], } for post_data in all_posts: if self.cancellation_event.is_set(): self.log_signal.emit("âš ī¸ Cancellation detected during task submission.") - break # Stop submitting new tasks + break if not isinstance(post_data, dict): self.log_signal.emit(f"âš ī¸ Skipping invalid post data item (type: {type(post_data)}).") - self.processed_posts_count += 1 # Count as processed (skipped) - self.total_posts_to_process -=1 # Adjust total if skipping invalid data + self.processed_posts_count += 1 + self.total_posts_to_process -=1 continue worker = PostProcessorWorker(post_data=post_data, **common_worker_args) try: - if self.thread_pool: # Check if pool still exists + if self.thread_pool: future = self.thread_pool.submit(worker.process) future.add_done_callback(self._handle_future_result) self.active_futures.append(future) - else: # Pool was shut down prematurely + else: self.log_signal.emit("âš ī¸ Thread pool shutdown before submitting all tasks.") break - except RuntimeError as e: # Handle pool shutdown error + except RuntimeError as e: self.log_signal.emit(f"âš ī¸ Error submitting task (pool might be shutting down): {e}") break except Exception as e: @@ -1497,9 +1441,8 @@ class DownloaderApp(QWidget): def _handle_future_result(self, future: Future): - """(Callback) Handles results from worker threads.""" self.processed_posts_count += 1 - downloaded_res, skipped_res = 0, 0 # Default results + downloaded_res, skipped_res = 0, 0 try: if future.cancelled(): @@ -1509,11 +1452,11 @@ class DownloaderApp(QWidget): self.log_signal.emit(f"❌ Error in worker thread: {exc}") pass else: - downloaded, skipped = future.result() # Result from worker.process() + downloaded, skipped = future.result() downloaded_res = downloaded skipped_res = skipped - with threading.Lock(): # Use a temporary lock for updating these counters + with threading.Lock(): self.download_counter += downloaded_res self.skip_counter += skipped_res self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) @@ -1521,12 +1464,12 @@ class DownloaderApp(QWidget): except Exception as e: self.log_signal.emit(f"❌ Error in result callback handling: {e}") if self.processed_posts_count >= self.total_posts_to_process and self.total_posts_to_process > 0: + if self.processed_posts_count >= self.total_posts_to_process: self.log_signal.emit("🏁 All submitted tasks have completed or failed.") cancelled = self.cancellation_event.is_set() self.finished_signal.emit(self.download_counter, self.skip_counter, cancelled) def set_ui_enabled(self, enabled): - """Enable/disable UI controls based on download state.""" self.download_btn.setEnabled(enabled) self.link_input.setEnabled(enabled) self.dir_input.setEnabled(enabled) @@ -1540,8 +1483,8 @@ class DownloaderApp(QWidget): self.compress_images_checkbox.setEnabled(enabled) self.download_thumbnails_checkbox.setEnabled(enabled) self.use_multithreading_checkbox.setEnabled(enabled) - self.skip_words_input.setEnabled(enabled) # --- NEW: Enable/disable skip words input --- - self.character_search_input.setEnabled(enabled) # ADDED + self.skip_words_input.setEnabled(enabled) + self.character_search_input.setEnabled(enabled) self.new_char_input.setEnabled(enabled) self.add_char_button.setEnabled(enabled) self.delete_char_button.setEnabled(enabled) @@ -1550,31 +1493,29 @@ class DownloaderApp(QWidget): self.character_filter_widget.setEnabled(enabled and subfolders_on) if enabled: self.update_ui_for_subfolders(subfolders_on) - self.update_custom_folder_visibility() # Update based on current URL + self.update_custom_folder_visibility() self.cancel_btn.setEnabled(not enabled) if enabled: self.skip_file_btn.setEnabled(False) def cancel_download(self): - """Requests cancellation of the ongoing download (single or multi-thread).""" - if not self.cancel_btn.isEnabled(): return # Prevent double clicks + if not self.cancel_btn.isEnabled(): return self.log_signal.emit("âš ī¸ Requesting cancellation...") - self.cancellation_event.set() # Set the shared event + self.cancellation_event.set() self.cancel_btn.setEnabled(False) self.progress_label.setText("Progress: Cancelling...") if self.thread_pool and self.active_futures: cancelled_count = 0 for future in self.active_futures: - if future.cancel(): # Attempts to cancel + if future.cancel(): cancelled_count += 1 if cancelled_count > 0: self.log_signal.emit(f" Attempted to cancel {cancelled_count} pending/running tasks.") def skip_current_file(self): - """Signals the active download thread (if single-threaded) to skip the current file.""" if self.download_thread and self.download_thread.isRunning(): - self.download_thread.skip_file() # Call method on the QThread instance + self.download_thread.skip_file() elif self.thread_pool: self.log_signal.emit("â„šī¸ Skipping individual files is not supported in multi-threaded mode.") QMessageBox.information(self, "Action Not Supported", "Skipping individual files is only available in single-threaded mode.") @@ -1583,7 +1524,6 @@ class DownloaderApp(QWidget): def update_skip_button_state(self, is_downloading_active): - """Enables/disables the skip button based on download state.""" can_skip = (not self.download_btn.isEnabled()) and \ (self.download_thread and self.download_thread.isRunning()) and \ is_downloading_active @@ -1594,7 +1534,6 @@ class DownloaderApp(QWidget): def download_finished(self, total_downloaded, total_skipped, cancelled): - """Cleans up resources and resets UI after download completion/cancellation.""" self.log_signal.emit("="*40) status = "Cancelled" if cancelled else "Finished" self.log_signal.emit(f"🏁 Download {status}!") @@ -1604,13 +1543,13 @@ class DownloaderApp(QWidget): if self.download_thread: try: self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result) - except TypeError: pass # Ignore if not connected + except TypeError: pass self.download_thread = None if self.thread_pool: self.log_signal.emit(" Shutting down worker thread pool...") self.thread_pool.shutdown(wait=False, cancel_futures=True) self.thread_pool = None - self.active_futures = [] # Clear future list + self.active_futures = [] self.cancellation_event.clear() self.set_ui_enabled(True) self.cancel_btn.setEnabled(False) @@ -1623,35 +1562,34 @@ class DownloaderApp(QWidget): result = (reply == QMessageBox.Yes) if result: - self.new_char_input.setText(character_name) # Pre-fill for clarity if needed? No, just add. + self.new_char_input.setText(character_name) if character_name.lower() not in {n.lower() for n in KNOWN_NAMES}: - self.add_new_character() # Add the name + self.add_new_character() if character_name.lower() not in {n.lower() for n in KNOWN_NAMES}: self.log_signal.emit(f"âš ī¸ Failed to add '{character_name}' via prompt. Check for errors.") - result = False # Treat as failure if not added + result = False else: self.log_signal.emit(f"â„šī¸ Filter name '{character_name}' was already present or added.") self.character_prompt_response_signal.emit(result) def receive_add_character_result(self, result): - """Slot to receive the boolean result from the GUI prompt.""" with QMutexLocker(self.prompt_mutex): self._add_character_response = result self.log_signal.emit(f" Received prompt response: {'Yes' if result else 'No'}") class DownloadThread(QThread): progress_signal = pyqtSignal(str) - add_character_prompt_signal = pyqtSignal(str) # Ask GUI to prompt user - file_download_status_signal = pyqtSignal(bool) # File download start/end - finished_signal = pyqtSignal(int, int, bool) # download_count, skip_count, cancelled_flag + add_character_prompt_signal = pyqtSignal(str) + file_download_status_signal = pyqtSignal(bool) + finished_signal = pyqtSignal(int, int, bool) def __init__(self, api_url, output_dir, known_names_copy, - cancellation_event, single_post_id=None, # Use shared cancellation event + cancellation_event, single_post_id=None, filter_character=None, filter_mode='all', skip_zip=True, skip_rar=True, use_subfolders=True, custom_folder_name=None, compress_images=False, download_thumbnails=False, service=None, user_id=None, downloaded_files=None, downloaded_files_lock=None, downloaded_file_hashes=None, downloaded_file_hashes_lock=None, - skip_words_list=None): # --- NEW: Accept skip_words_list --- + skip_words_list=None): super().__init__() self._init_failed = False self.api_url_input = api_url @@ -1667,33 +1605,32 @@ class DownloadThread(QThread): self.custom_folder_name = custom_folder_name self.compress_images = compress_images self.download_thumbnails = download_thumbnails - self.service = service # Use passed value - self.user_id = user_id # Use passed value - self.skip_words_list = skip_words_list if skip_words_list is not None else [] # --- NEW: Store skip_words_list --- + self.service = service + self.user_id = user_id + self.skip_words_list = skip_words_list if skip_words_list is not None else [] self.downloaded_files = downloaded_files if downloaded_files is not None else set() self.downloaded_files_lock = downloaded_files_lock if downloaded_files_lock is not None else threading.Lock() - self.downloaded_file_hashes = downloaded_file_hashes if downloaded_file_hashes is not None else set() # ADDED - self.downloaded_file_hashes_lock = downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading.Lock() # ADDED + self.downloaded_file_hashes = downloaded_file_hashes if downloaded_file_hashes is not None else set() + self.downloaded_file_hashes_lock = downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading.Lock() self.skip_current_file_flag = threading.Event() self.is_downloading_file = False self.current_download_path = None - self._add_character_response = None # Stores response from GUI prompt - self.prompt_mutex = QMutex() # Protects access to _add_character_response + self._add_character_response = None + self.prompt_mutex = QMutex() if not self.service or not self.user_id: log_msg = f"❌ Thread Init Error: Missing service ('{self.service}') or user ID ('{self.user_id}') for URL '{api_url}'" - print(log_msg) # Print error as signals might not be connected yet + print(log_msg) try: self.progress_signal.emit(log_msg) - except RuntimeError: pass # Ignore if signal connection fails during init + except RuntimeError: pass self._init_failed = True def run(self): - """Main execution logic for the single-threaded download.""" if self._init_failed: - self.finished_signal.emit(0, 0, False) # Signal completion with zero counts + self.finished_signal.emit(0, 0, False) return - unwanted_keywords = {'spicy', 'hd', 'nsfw', '4k', 'preview'} # Example unwanted keywords + unwanted_keywords = {'spicy', 'hd', 'nsfw', '4k', 'preview'} grand_total_downloaded = 0 grand_total_skipped = 0 cancelled_by_user = False @@ -1701,16 +1638,16 @@ class DownloadThread(QThread): try: if self.use_subfolders and self.filter_character and not self.custom_folder_name: if not self._check_and_prompt_filter_character(): - self.finished_signal.emit(0, 0, False) # Not cancelled, aborted by validation/user + self.finished_signal.emit(0, 0, False) return worker_signals_adapter = PostProcessorSignals() - worker_signals_adapter.progress_signal.connect(self.progress_signal) # Route log messages - worker_signals_adapter.file_download_status_signal.connect(self.file_download_status_signal) # Route file status + worker_signals_adapter.progress_signal.connect(self.progress_signal) + worker_signals_adapter.file_download_status_signal.connect(self.file_download_status_signal) post_worker = PostProcessorWorker( - post_data=None, # Will be set per post below + post_data=None, download_root=self.output_dir, - known_names=self.known_names, # Use thread's (potentially updated) list + known_names=self.known_names, filter_character=self.filter_character, unwanted_keywords=unwanted_keywords, filter_mode=self.filter_mode, @@ -1724,13 +1661,13 @@ class DownloadThread(QThread): service=self.service, user_id=self.user_id, api_url_input=self.api_url_input, - cancellation_event=self.cancellation_event, # Pass the shared event - signals=worker_signals_adapter, # Use the adapter signals + cancellation_event=self.cancellation_event, + signals=worker_signals_adapter, downloaded_files=self.downloaded_files, downloaded_files_lock=self.downloaded_files_lock, - downloaded_file_hashes=self.downloaded_file_hashes, # ADDED - downloaded_file_hashes_lock=self.downloaded_file_hashes_lock, # ADDED - skip_words_list=self.skip_words_list, # --- NEW: Pass skip words to worker --- + downloaded_file_hashes=self.downloaded_file_hashes, + downloaded_file_hashes_lock=self.downloaded_file_hashes_lock, + skip_words_list=self.skip_words_list, ) post_worker.skip_current_file_flag = self.skip_current_file_flag self.progress_signal.emit(" Starting post fetch...") @@ -1740,16 +1677,16 @@ class DownloadThread(QThread): post_generator = download_from_api(self.api_url_input, logger=thread_logger) for posts_batch in post_generator: - if self.isInterruptionRequested(): # Checks QThread interrupt AND cancellation_event + if self.isInterruptionRequested(): self.progress_signal.emit("âš ī¸ Download cancelled before processing batch.") cancelled_by_user = True - break # Exit fetch loop + break for post in posts_batch: if self.isInterruptionRequested(): self.progress_signal.emit("âš ī¸ Download cancelled during post processing.") cancelled_by_user = True - break # Exit inner post loop + break post_worker.post = post try: downloaded, skipped = post_worker.process() @@ -1760,11 +1697,11 @@ class DownloadThread(QThread): self.progress_signal.emit(f"❌ Error processing post {post_id_err}: {proc_e}") import traceback self.progress_signal.emit(traceback.format_exc(limit=2)) - grand_total_skipped += 1 # Count post as skipped on error - self.msleep(20) # 20 milliseconds + grand_total_skipped += 1 + self.msleep(20) if cancelled_by_user: - break # Exit outer batch loop as well + break if not cancelled_by_user: self.progress_signal.emit("✅ Post fetching and processing complete.") @@ -1778,20 +1715,19 @@ class DownloadThread(QThread): for line in tb_str.splitlines(): self.progress_signal.emit(" " + line) self.progress_signal.emit("--- End Traceback ---") - cancelled_by_user = False # Not cancelled by user, but by error + cancelled_by_user = False finally: self.finished_signal.emit(grand_total_downloaded, grand_total_skipped, cancelled_by_user) def _check_and_prompt_filter_character(self): - """Validates filter character and prompts user if it's not known. Returns True if OK to proceed.""" clean_char_filter = clean_folder_name(self.filter_character.lower()) known_names_lower = {name.lower() for name in self.known_names} if not clean_char_filter: self.progress_signal.emit(f"❌ Filter name '{self.filter_character}' is invalid. Aborting.") - return False # Invalid filter + return False if self.filter_character.lower() not in known_names_lower: self.progress_signal.emit(f"❓ Filter '{self.filter_character}' not found in known list.") @@ -1800,46 +1736,49 @@ class DownloadThread(QThread): self.add_character_prompt_signal.emit(self.filter_character) self.progress_signal.emit(" Waiting for user confirmation to add filter name...") while self._add_character_response is None: - if self.isInterruptionRequested(): # Check cancellation + if self.isInterruptionRequested(): self.progress_signal.emit("âš ī¸ Cancelled while waiting for user input on filter name.") - return False # Abort if cancelled - self.msleep(200) # Check every 200ms + return False + self.msleep(200) if self._add_character_response: self.progress_signal.emit(f"✅ User confirmed adding '{self.filter_character}'. Continuing.") if self.filter_character not in self.known_names: self.known_names.append(self.filter_character) - return True # OK to proceed + return True else: self.progress_signal.emit(f"❌ User declined to add filter '{self.filter_character}'. Aborting download.") - return False # User declined, abort + return False return True def skip_file(self): - """Sets the skip flag for the currently downloading file.""" if self.isRunning() and self.is_downloading_file: self.progress_signal.emit("â­ī¸ Skip requested for current file.") - self.skip_current_file_flag.set() # Signal the worker's process loop + self.skip_current_file_flag.set() elif self.isRunning(): self.progress_signal.emit("â„šī¸ Skip requested, but no file download active.") def receive_add_character_result(self, result): - """Slot to receive the boolean result from the GUI prompt.""" with QMutexLocker(self.prompt_mutex): self._add_character_response = result self.progress_signal.emit(f" Received prompt response: {'Yes' if result else 'No'}") def isInterruptionRequested(self): - """Overrides QThread method to check both interruption flag and shared event.""" return super().isInterruptionRequested() or self.cancellation_event.is_set() if __name__ == '__main__': - qt_app = QApplication(sys.argv) + app = QApplication(sys.argv) + app.setWindowIcon(QIcon("Kemono.ico")) + from PyQt5.QtGui import QIcon + app.setWindowIcon(QIcon("Kemono.ico")) - downloader = DownloaderApp() # Create the main application window - downloader.show() # Show the window + qt_app = QApplication(sys.argv) + qt_app.setWindowIcon(QIcon(os.path.join(os.path.dirname(__file__), 'Kemono.ico'))) + + downloader = DownloaderApp() + downloader.show() exit_code = qt_app.exec_() print(f"Application finished with exit code: {exit_code}") - sys.exit(exit_code) # Exit the script with the application's exit code \ No newline at end of file + sys.exit(exit_code) \ No newline at end of file