From e3dd0e70b67edc29be72d4e61c1b0364a0f48829 Mon Sep 17 00:00:00 2001 From: Yuvi9587 <114073886+Yuvi9587@users.noreply.github.com> Date: Sun, 27 Jul 2025 06:32:15 -0700 Subject: [PATCH] commit --- src/core/api_client.py | 67 ++++++- src/core/manager.py | 168 +++++++++------- src/core/workers.py | 25 ++- src/services/multipart_downloader.py | 159 ++++++++------- src/ui/dialogs/EmptyPopupDialog.py | 12 +- src/ui/dialogs/FutureSettingsDialog.py | 62 ++++-- src/ui/dialogs/MoreOptionsDialog.py | 2 +- src/ui/main_window.py | 262 +++++++++++++++++++------ src/utils/network_utils.py | 5 +- 9 files changed, 508 insertions(+), 254 deletions(-) diff --git a/src/core/api_client.py b/src/core/api_client.py index e4a7490..b74d89d 100644 --- a/src/core/api_client.py +++ b/src/core/api_client.py @@ -120,7 +120,7 @@ def download_from_api( selected_cookie_file=None, app_base_dir=None, manga_filename_style_for_sort_check=None, - processed_post_ids=None # --- ADD THIS ARGUMENT --- + processed_post_ids=None ): headers = { 'User-Agent': 'Mozilla/5.0', @@ -139,9 +139,19 @@ def download_from_api( 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" + fallback_api_domain = None + + # --- START: MODIFIED DOMAIN LOGIC WITH FALLBACK --- + if 'kemono.cr' in api_domain.lower(): + fallback_api_domain = 'kemono.su' + elif 'coomer.st' in api_domain.lower(): + fallback_api_domain = 'coomer.su' + elif not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']): + logger(f"⚠️ Unrecognized domain '{api_domain}'. Defaulting to kemono.cr with fallback to kemono.su.") + api_domain = "kemono.cr" + fallback_api_domain = "kemono.su" + # --- END: MODIFIED DOMAIN LOGIC WITH FALLBACK --- + 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) @@ -178,7 +188,6 @@ def download_from_api( logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).") 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...") @@ -191,6 +200,12 @@ def download_from_api( 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}.") + + # --- START: MANGA MODE FALLBACK LOGIC --- + is_first_page_attempt_manga = True + api_base_url_manga = f"https://{api_domain}/api/v1/{service}/user/{user_id}" + # --- END: MANGA MODE FALLBACK LOGIC --- + while True: if pause_event and pause_event.is_set(): logger(" Manga mode post fetching paused...") @@ -208,7 +223,10 @@ def download_from_api( 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) + # --- START: MANGA MODE FALLBACK EXECUTION --- + posts_batch_manga = fetch_posts_paginated(api_base_url_manga, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) + is_first_page_attempt_manga = False # Success, no need to fallback + # --- END: MANGA MODE FALLBACK EXECUTION --- if not isinstance(posts_batch_manga, list): logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.") break @@ -220,9 +238,21 @@ def download_from_api( logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).") break all_posts_for_manga_mode.extend(posts_batch_manga) + + logger(f"MANGA_FETCH_PROGRESS:{len(all_posts_for_manga_mode)}:{current_page_num_manga}") + current_offset_manga += page_size time.sleep(0.6) except RuntimeError as e: + # --- START: MANGA MODE FALLBACK HANDLING --- + if is_first_page_attempt_manga and fallback_api_domain: + logger(f" ⚠️ Initial API fetch (Manga Mode) from '{api_domain}' failed: {e}") + logger(f" ↪️ Falling back to old domain: '{fallback_api_domain}'") + api_domain = fallback_api_domain + api_base_url_manga = f"https://{api_domain}/api/v1/{service}/user/{user_id}" + is_first_page_attempt_manga = False + continue # Retry the same offset with the new domain + # --- END: MANGA MODE FALLBACK HANDLING --- if "cancelled by user" in str(e).lower(): logger(f"ℹ️ Manga mode pagination stopped due to cancellation: {e}") else: @@ -232,7 +262,12 @@ def download_from_api( logger(f"❌ Unexpected error during manga mode fetch: {e}") traceback.print_exc() break + if cancellation_event and cancellation_event.is_set(): return + + if all_posts_for_manga_mode: + logger(f"MANGA_FETCH_COMPLETE:{len(all_posts_for_manga_mode)}") + if all_posts_for_manga_mode: if processed_post_ids: original_count = len(all_posts_for_manga_mode) @@ -278,6 +313,12 @@ def download_from_api( current_offset = (start_page - 1) * page_size current_page_num = start_page logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).") + + # --- START: STANDARD PAGINATION FALLBACK LOGIC --- + is_first_page_attempt = True + api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}" + # --- END: STANDARD PAGINATION FALLBACK LOGIC --- + while True: if pause_event and pause_event.is_set(): logger(" Post fetching loop paused...") @@ -296,11 +337,23 @@ def download_from_api( logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.") break try: + # --- START: STANDARD PAGINATION FALLBACK EXECUTION --- posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) + is_first_page_attempt = False # Success, no more fallbacks needed + # --- END: STANDARD PAGINATION FALLBACK EXECUTION --- if not isinstance(posts_batch, list): logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).") break except RuntimeError as e: + # --- START: STANDARD PAGINATION FALLBACK HANDLING --- + if is_first_page_attempt and fallback_api_domain: + logger(f" ⚠️ Initial API fetch from '{api_domain}' failed: {e}") + logger(f" ↪️ Falling back to old domain: '{fallback_api_domain}'") + api_domain = fallback_api_domain + api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}" + is_first_page_attempt = False + continue # Retry the same offset with the new domain + # --- END: STANDARD PAGINATION FALLBACK HANDLING --- if "cancelled by user" in str(e).lower(): logger(f"ℹ️ Pagination stopped due to cancellation: {e}") else: @@ -340,4 +393,4 @@ def download_from_api( current_page_num += 1 time.sleep(0.6) if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()): - logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).") + logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).") \ No newline at end of file diff --git a/src/core/manager.py b/src/core/manager.py index 5e883de..70ace44 100644 --- a/src/core/manager.py +++ b/src/core/manager.py @@ -5,11 +5,10 @@ import json import traceback from concurrent.futures import ThreadPoolExecutor, as_completed, Future from .api_client import download_from_api -from .workers import PostProcessorWorker, DownloadThread +from .workers import PostProcessorWorker from ..config.constants import ( STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING, - MAX_THREADS, POST_WORKER_BATCH_THRESHOLD, POST_WORKER_NUM_BATCHES, - POST_WORKER_BATCH_DELAY_SECONDS + MAX_THREADS ) from ..utils.file_utils import clean_folder_name @@ -44,6 +43,7 @@ class DownloadManager: self.creator_profiles_dir = None self.current_creator_name_for_profile = None self.current_creator_profile_path = None + self.session_file_path = None def _log(self, message): """Puts a progress message into the queue for the UI.""" @@ -61,12 +61,16 @@ class DownloadManager: if self.is_running: self._log("❌ Cannot start a new session: A session is already in progress.") return - + + self.session_file_path = config.get('session_file_path') creator_profile_data = self._setup_creator_profile(config) - creator_profile_data['settings'] = config - creator_profile_data.setdefault('processed_post_ids', []) - self._save_creator_profile(creator_profile_data) - self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.") + + # Save settings to profile at the start of the session + if self.current_creator_profile_path: + creator_profile_data['settings'] = config + creator_profile_data.setdefault('processed_post_ids', []) + self._save_creator_profile(creator_profile_data) + self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.") self.is_running = True self.cancellation_event.clear() @@ -77,6 +81,7 @@ class DownloadManager: self.total_downloads = 0 self.total_skips = 0 self.all_kept_original_filenames = [] + is_single_post = bool(config.get('target_post_id_from_initial_url')) use_multithreading = config.get('use_multithreading', True) is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] @@ -86,72 +91,99 @@ class DownloadManager: if should_use_multithreading_for_posts: fetcher_thread = threading.Thread( target=self._fetch_and_queue_posts_for_pool, - args=(config, restore_data, creator_profile_data), # Add argument here + args=(config, restore_data, creator_profile_data), daemon=True ) fetcher_thread.start() else: - self._start_single_threaded_session(config) + # Single-threaded mode does not use the manager's complex logic + self._log("ℹ️ Manager is handing off to a single-threaded worker...") + # The single-threaded worker will manage its own lifecycle and signals. + # The manager's role for this session is effectively over. + self.is_running = False # Allow another session to start if needed + self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)}) - def _start_single_threaded_session(self, config): - """Handles downloads that are best processed by a single worker thread.""" - self._log("ℹ️ Initializing single-threaded download process...") - self.worker_thread = threading.Thread( - target=self._run_single_worker, - args=(config,), - daemon=True - ) - self.worker_thread.start() - def _run_single_worker(self, config): - """Target function for the single-worker thread.""" - try: - worker = DownloadThread(config, self.progress_queue) - worker.run() # This is the main blocking call for this thread - except Exception as e: - self._log(f"❌ CRITICAL ERROR in single-worker thread: {e}") - self._log(traceback.format_exc()) - finally: - self.is_running = False - - def _fetch_and_queue_posts_for_pool(self, config, restore_data): + def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data): """ - Fetches all posts from the API and submits them as tasks to a thread pool. - This method runs in its own dedicated thread to avoid blocking. + Fetches posts from the API in batches and submits them as tasks to a thread pool. + This method runs in its own dedicated thread to avoid blocking the UI. + It provides immediate feedback as soon as the first batch of posts is found. """ try: num_workers = min(config.get('num_threads', 4), MAX_THREADS) self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') - session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set() + session_processed_ids = set(restore_data.get('processed_post_ids', [])) if restore_data else set() profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) processed_ids = session_processed_ids.union(profile_processed_ids) - if restore_data: + if restore_data and 'all_posts_data' in restore_data: + # This logic for session restore remains as it relies on a pre-fetched list all_posts = restore_data['all_posts_data'] - processed_ids = set(restore_data['processed_post_ids']) posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids] self.total_posts = len(all_posts) self.processed_posts = len(processed_ids) self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.") + self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) + + if not posts_to_process: + self._log("✅ No new posts to process from restored session.") + return + + for post_data in posts_to_process: + if self.cancellation_event.is_set(): break + worker = PostProcessorWorker(post_data, config, self.progress_queue) + future = self.thread_pool.submit(worker.process) + future.add_done_callback(self._handle_future_result) + self.active_futures.append(future) else: - posts_to_process = self._get_all_posts(config) - self.total_posts = len(posts_to_process) + # --- START: REFACTORED STREAMING LOGIC --- + post_generator = download_from_api( + api_url_input=config['api_url'], + logger=self._log, + start_page=config.get('start_page'), + end_page=config.get('end_page'), + manga_mode=config.get('manga_mode_active', False), + cancellation_event=self.cancellation_event, + pause_event=self.pause_event, + use_cookie=config.get('use_cookie', False), + cookie_text=config.get('cookie_text', ''), + selected_cookie_file=config.get('selected_cookie_file'), + app_base_dir=config.get('app_base_dir'), + manga_filename_style_for_sort_check=config.get('manga_filename_style'), + processed_post_ids=list(processed_ids) + ) + + self.total_posts = 0 self.processed_posts = 0 - self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) - - if not posts_to_process: - self._log("✅ No new posts to process.") - return - for post_data in posts_to_process: - if self.cancellation_event.is_set(): - break - worker = PostProcessorWorker(post_data, config, self.progress_queue) - future = self.thread_pool.submit(worker.process) - future.add_done_callback(self._handle_future_result) - self.active_futures.append(future) - + # Process posts in batches as they are yielded by the API client + for batch in post_generator: + if self.cancellation_event.is_set(): + self._log(" Post fetching cancelled.") + break + + # Filter out any posts that might have been processed since the start + posts_in_batch_to_process = [p for p in batch if p.get('id') not in processed_ids] + + if not posts_in_batch_to_process: + continue + + # Update total count and immediately inform the UI + self.total_posts += len(posts_in_batch_to_process) + self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) + + for post_data in posts_in_batch_to_process: + if self.cancellation_event.is_set(): break + worker = PostProcessorWorker(post_data, config, self.progress_queue) + future = self.thread_pool.submit(worker.process) + future.add_done_callback(self._handle_future_result) + self.active_futures.append(future) + + if self.total_posts == 0 and not self.cancellation_event.is_set(): + self._log("✅ No new posts found to process.") + except Exception as e: self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}") self._log(traceback.format_exc()) @@ -164,28 +196,6 @@ class DownloadManager: 'type': 'finished', 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) }) - - def _get_all_posts(self, config): - """Helper to fetch all posts using the API client.""" - all_posts = [] - post_generator = download_from_api( - api_url_input=config['api_url'], - logger=self._log, - start_page=config.get('start_page'), - end_page=config.get('end_page'), - manga_mode=config.get('manga_mode_active', False), - cancellation_event=self.cancellation_event, - pause_event=self.pause_event, - use_cookie=config.get('use_cookie', False), - cookie_text=config.get('cookie_text', ''), - selected_cookie_file=config.get('selected_cookie_file'), - app_base_dir=config.get('app_base_dir'), - manga_filename_style_for_sort_check=config.get('manga_filename_style'), - processed_post_ids=config.get('processed_post_ids', []) - ) - for batch in post_generator: - all_posts.extend(batch) - return all_posts def _handle_future_result(self, future: Future): """Callback executed when a worker task completes.""" @@ -261,9 +271,15 @@ class DownloadManager: """Cancels the current running session.""" if not self.is_running: return + + if self.cancellation_event.is_set(): + self._log("ℹ️ Cancellation already in progress.") + return + self._log("⚠️ Cancellation requested by user...") self.cancellation_event.set() + if self.thread_pool: - self.thread_pool.shutdown(wait=False, cancel_futures=True) - - self.is_running = False + self._log(" Signaling all worker threads to stop and shutting down pool...") + self.thread_pool.shutdown(wait=False) + diff --git a/src/core/workers.py b/src/core/workers.py index 6e8fba4..be8b909 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -1,4 +1,5 @@ import os +import sys import queue import re import threading @@ -1175,11 +1176,18 @@ class PostProcessorWorker: if FPDF: self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...") pdf = PDF() + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # If the application is run as a bundled exe, _MEIPASS is the temp folder + base_path = sys._MEIPASS + else: + # If running as a normal .py script, use the project_root_dir + base_path = self.project_root_dir + font_path = "" bold_font_path = "" - if self.project_root_dir: - font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') - bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf') + if base_path: + font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf') try: if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") @@ -1666,10 +1674,12 @@ class PostProcessorWorker: 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: + # Check if the path is a directory and if it's empty 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: + # Log if removal fails for any reason (e.g., permissions) self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}") result_tuple = (total_downloaded_this_post, total_skipped_this_post, @@ -1678,6 +1688,15 @@ class PostProcessorWorker: None) finally: + 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 potentially empty subfolder '{path_to_check_for_emptiness}': {e_rmdir}") + self._emit_signal('worker_finished', result_tuple) return result_tuple diff --git a/src/services/multipart_downloader.py b/src/services/multipart_downloader.py index b7db348..3509569 100644 --- a/src/services/multipart_downloader.py +++ b/src/services/multipart_downloader.py @@ -17,7 +17,6 @@ MAX_CHUNK_DOWNLOAD_RETRIES = 1 DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk # Flag to indicate if this module and its dependencies are available. -# This was missing and caused the ImportError. MULTIPART_DOWNLOADER_AVAILABLE = True @@ -49,87 +48,97 @@ def _download_individual_chunk( time.sleep(0.2) logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.") - # Prepare headers for the specific byte range of this chunk - chunk_headers = headers.copy() - if end_byte != -1: - chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}" - - bytes_this_chunk = 0 - last_speed_calc_time = time.time() - bytes_at_last_speed_calc = 0 + # --- START: FIX --- + # Set this chunk's status to 'active' before starting the download. + with progress_data['lock']: + progress_data['chunks_status'][part_num]['active'] = True + # --- END: FIX --- - # --- Retry Loop --- - for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1): - if cancellation_event and cancellation_event.is_set(): - return bytes_this_chunk, False + try: + # Prepare headers for the specific byte range of this chunk + chunk_headers = headers.copy() + if end_byte != -1: + chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}" + + bytes_this_chunk = 0 + last_speed_calc_time = time.time() + bytes_at_last_speed_calc = 0 - try: - if attempt > 0: - logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...") - time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1))) - last_speed_calc_time = time.time() - bytes_at_last_speed_calc = bytes_this_chunk + # --- Retry Loop --- + for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1): + if cancellation_event and cancellation_event.is_set(): + return bytes_this_chunk, False - logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}") - - response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk) - response.raise_for_status() + try: + if attempt > 0: + logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...") + time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1))) + last_speed_calc_time = time.time() + bytes_at_last_speed_calc = bytes_this_chunk - # --- Data Writing Loop --- - with open(temp_file_path, 'r+b') as f: - f.seek(start_byte) - for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER): - if cancellation_event and cancellation_event.is_set(): - return bytes_this_chunk, False - if pause_event and pause_event.is_set(): - # Handle pausing during the download stream - logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...") - while pause_event.is_set(): - if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False - time.sleep(0.2) - logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.") + logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}") + + response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk) + response.raise_for_status() - if data_segment: - f.write(data_segment) - bytes_this_chunk += len(data_segment) - - # Update shared progress data structure - with progress_data['lock']: - progress_data['total_downloaded_so_far'] += len(data_segment) - progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk + # --- Data Writing Loop --- + with open(temp_file_path, 'r+b') as f: + f.seek(start_byte) + for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER): + if cancellation_event and cancellation_event.is_set(): + return bytes_this_chunk, False + if pause_event and pause_event.is_set(): + # Handle pausing during the download stream + logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...") + while pause_event.is_set(): + if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False + time.sleep(0.2) + logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.") + + if data_segment: + f.write(data_segment) + bytes_this_chunk += len(data_segment) - # Calculate and update speed for this chunk - current_time = time.time() - time_delta = current_time - last_speed_calc_time - if time_delta > 0.5: - bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc - current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0 - progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps - last_speed_calc_time = current_time - bytes_at_last_speed_calc = bytes_this_chunk - - # Emit progress signal to the UI via the queue - if emitter and (current_time - global_emit_time_ref[0] > 0.25): - global_emit_time_ref[0] = current_time - status_list_copy = [dict(s) for s in progress_data['chunks_status']] - if isinstance(emitter, queue.Queue): - emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)}) - elif hasattr(emitter, 'file_progress_signal'): - emitter.file_progress_signal.emit(api_original_filename, status_list_copy) - - # If we reach here, the download for this chunk was successful - return bytes_this_chunk, True + # Update shared progress data structure + with progress_data['lock']: + progress_data['total_downloaded_so_far'] += len(data_segment) + progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk + + # Calculate and update speed for this chunk + current_time = time.time() + time_delta = current_time - last_speed_calc_time + if time_delta > 0.5: + bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc + current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0 + progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps + last_speed_calc_time = current_time + bytes_at_last_speed_calc = bytes_this_chunk + + # Emit progress signal to the UI via the queue + if emitter and (current_time - global_emit_time_ref[0] > 0.25): + global_emit_time_ref[0] = current_time + status_list_copy = [dict(s) for s in progress_data['chunks_status']] + if isinstance(emitter, queue.Queue): + emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)}) + elif hasattr(emitter, 'file_progress_signal'): + emitter.file_progress_signal.emit(api_original_filename, status_list_copy) + + return bytes_this_chunk, True - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e: - logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}") - except requests.exceptions.RequestException as e: - logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}") - return bytes_this_chunk, False # Break loop on non-retryable errors - except Exception as e: - logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}") - return bytes_this_chunk, False + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e: + logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}") + except requests.exceptions.RequestException as e: + logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}") + return bytes_this_chunk, False # Break loop on non-retryable errors + except Exception as e: + logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}") + return bytes_this_chunk, False - return bytes_this_chunk, False + return bytes_this_chunk, False + finally: + with progress_data['lock']: + progress_data['chunks_status'][part_num]['active'] = False + progress_data['chunks_status'][part_num]['speed_bps'] = 0.0 def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename, @@ -225,4 +234,4 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, if os.path.exists(temp_file_path): try: os.remove(temp_file_path) except OSError as e: logger_func(f" Failed to remove temp part file '{temp_file_path}': {e}") - return False, total_bytes_from_chunks, None, None \ No newline at end of file + return False, total_bytes_from_chunks, None, None diff --git a/src/ui/dialogs/EmptyPopupDialog.py b/src/ui/dialogs/EmptyPopupDialog.py index 5978883..b465b1e 100644 --- a/src/ui/dialogs/EmptyPopupDialog.py +++ b/src/ui/dialogs/EmptyPopupDialog.py @@ -960,15 +960,16 @@ class EmptyPopupDialog (QDialog ): self .parent_app .log_signal .emit (f"ℹ️ Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.") + # --- START: MODIFIED LOGIC --- + # Removed the blockSignals(True/False) calls to allow the main window's UI to update correctly. if self .parent_app .link_input : - self .parent_app .link_input .blockSignals (True ) self .parent_app .link_input .setText ( self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts ) ) - self .parent_app .link_input .blockSignals (False ) self .parent_app .link_input .setPlaceholderText ( self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue ) ) + # --- END: MODIFIED LOGIC --- self.selected_creators_for_queue.clear() @@ -989,15 +990,12 @@ class EmptyPopupDialog (QDialog ): self .add_selected_button .setEnabled (True ) self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection")) - - - def _get_domain_for_service (self ,service_name ): """Determines the base domain for a given service.""" service_lower =service_name .lower () if service_lower in ['onlyfans','fansly']: - return "coomer.su" - return "kemono.su" + return "coomer.st" + return "kemono.cr" def _handle_add_selected (self ): """Gathers globally selected creators and processes them.""" diff --git a/src/ui/dialogs/FutureSettingsDialog.py b/src/ui/dialogs/FutureSettingsDialog.py index 44069e0..94361a2 100644 --- a/src/ui/dialogs/FutureSettingsDialog.py +++ b/src/ui/dialogs/FutureSettingsDialog.py @@ -15,7 +15,8 @@ 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, - RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY + RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY, + COOKIE_TEXT_KEY, USE_COOKIE_KEY ) @@ -89,7 +90,9 @@ class FutureSettingsDialog(QDialog): # Default Path self.default_path_label = QLabel() self.save_path_button = QPushButton() - self.save_path_button.clicked.connect(self._save_download_path) + # --- START: MODIFIED LOGIC --- + self.save_path_button.clicked.connect(self._save_cookie_and_path) + # --- END: MODIFIED LOGIC --- download_window_layout.addWidget(self.default_path_label, 1, 0) download_window_layout.addWidget(self.save_path_button, 1, 1) @@ -143,11 +146,13 @@ class FutureSettingsDialog(QDialog): self.default_path_label.setText(self._tr("default_path_label", "Default Path:")) self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file")) + # --- START: MODIFIED LOGIC --- # Buttons and Controls self._update_theme_toggle_button_text() - self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path")) - self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions.")) + self.save_path_button.setText(self._tr("settings_save_cookie_path_button", "Save Cookie + Download Path")) + self.save_path_button.setToolTip(self._tr("settings_save_cookie_path_tooltip", "Save the current 'Download Location' and Cookie settings for future sessions.")) self.ok_button.setText(self._tr("ok_button", "OK")) + # --- END: MODIFIED LOGIC --- # Populate dropdowns self._populate_display_combo_boxes() @@ -275,22 +280,43 @@ class FutureSettingsDialog(QDialog): if msg_box.clickedButton() == restart_button: self.parent_app._request_restart_application() - def _save_download_path(self): + def _save_cookie_and_path(self): + """Saves the current download path and/or cookie settings from the main window.""" + path_saved = False + cookie_saved = False + + # --- Save Download Path Logic --- if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input: current_path = self.parent_app.dir_input.text().strip() if current_path and os.path.isdir(current_path): self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path) - self.parent_app.settings.sync() - QMessageBox.information(self, - self._tr("settings_save_path_success_title", "Path Saved"), - self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path)) - elif not current_path: - QMessageBox.warning(self, - self._tr("settings_save_path_empty_title", "Empty Path"), - self._tr("settings_save_path_empty_message", "Download location cannot be empty.")) - else: - QMessageBox.warning(self, - self._tr("settings_save_path_invalid_title", "Invalid Path"), - self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path)) + path_saved = True + + # --- Save Cookie Logic --- + if hasattr(self.parent_app, 'use_cookie_checkbox'): + use_cookie = self.parent_app.use_cookie_checkbox.isChecked() + cookie_content = self.parent_app.cookie_text_input.text().strip() + + if use_cookie and cookie_content: + self.parent_app.settings.setValue(USE_COOKIE_KEY, True) + self.parent_app.settings.setValue(COOKIE_TEXT_KEY, cookie_content) + cookie_saved = True + else: # Also save the 'off' state + self.parent_app.settings.setValue(USE_COOKIE_KEY, False) + self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "") + + self.parent_app.settings.sync() + + # --- User Feedback --- + if path_saved and cookie_saved: + message = self._tr("settings_save_both_success", "Download location and cookie settings saved.") + elif path_saved: + message = self._tr("settings_save_path_only_success", "Download location saved. No cookie settings were active to save.") + elif cookie_saved: + message = self._tr("settings_save_cookie_only_success", "Cookie settings saved. Download location was not set.") else: - QMessageBox.critical(self, "Error", "Could not access download path input from main application.") \ No newline at end of file + QMessageBox.warning(self, self._tr("settings_save_nothing_title", "Nothing to Save"), + self._tr("settings_save_nothing_message", "The download location is not a valid directory and no cookie was active.")) + return + + QMessageBox.information(self, self._tr("settings_save_success_title", "Settings Saved"), message) diff --git a/src/ui/dialogs/MoreOptionsDialog.py b/src/ui/dialogs/MoreOptionsDialog.py index 5ead46f..ec34564 100644 --- a/src/ui/dialogs/MoreOptionsDialog.py +++ b/src/ui/dialogs/MoreOptionsDialog.py @@ -24,7 +24,7 @@ class MoreOptionsDialog(QDialog): layout.addWidget(self.description_label) self.radio_button_group = QButtonGroup(self) self.radio_content = QRadioButton("Description/Content") - self.radio_comments = QRadioButton("Comments (Not Working)") + self.radio_comments = QRadioButton("Comments") self.radio_button_group.addButton(self.radio_content) self.radio_button_group.addButton(self.radio_comments) layout.addWidget(self.radio_content) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 4938fec..9ab046e 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -105,6 +105,7 @@ class DownloaderApp (QWidget ): self.active_update_profile = None self.new_posts_for_update = [] self.is_finishing = False + self.finish_lock = threading.Lock() saved_res = self.settings.value(RESOLUTION_KEY, "Auto") if saved_res != "Auto": @@ -266,7 +267,7 @@ class DownloaderApp (QWidget ): self.download_location_label_widget = None self.remove_from_filename_label_widget = None self.skip_words_label_widget = None - self.setWindowTitle("Kemono Downloader v6.2.0") + self.setWindowTitle("Kemono Downloader v6.2.1") setup_ui(self) self._connect_signals() self.log_signal.emit("ℹ️ Local API server functionality has been removed.") @@ -284,6 +285,7 @@ class DownloaderApp (QWidget ): self._retranslate_main_ui() self._load_persistent_history() self._load_saved_download_location() + self._load_saved_cookie_settings() self._update_button_states_and_connections() self._check_for_interrupted_session() @@ -1570,6 +1572,31 @@ class DownloaderApp (QWidget ): QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }") def handle_main_log(self, message): + if isinstance(message, str) and message.startswith("MANGA_FETCH_PROGRESS:"): + try: + parts = message.split(":") + fetched_count = int(parts[1]) + page_num = int(parts[2]) + self.progress_label.setText(self._tr("progress_fetching_manga_pages", "Progress: Fetching Page {page} ({count} posts found)...").format(page=page_num, count=fetched_count)) + QCoreApplication.processEvents() + except (ValueError, IndexError): + try: + fetched_count = int(message.split(":")[1]) + self.progress_label.setText(self._tr("progress_fetching_manga_posts", "Progress: Fetching Manga Posts ({count})...").format(count=fetched_count)) + QCoreApplication.processEvents() + except (ValueError, IndexError): + pass + return + elif isinstance(message, str) and message.startswith("MANGA_FETCH_COMPLETE:"): + try: + total_posts = int(message.split(":")[1]) + self.total_posts_to_process = total_posts + self.processed_posts_count = 0 + self.update_progress_display(self.total_posts_to_process, self.processed_posts_count) + except (ValueError, IndexError): + pass + return + if message.startswith("TEMP_FILE_PATH:"): filepath = message.split(":", 1)[1] if self.single_pdf_setting: @@ -2561,23 +2588,42 @@ class DownloaderApp (QWidget ): self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).") def _toggle_manga_filename_style (self ): - current_style =self .manga_filename_style - new_style ="" - if current_style ==STYLE_POST_TITLE : - new_style =STYLE_ORIGINAL_NAME - elif current_style ==STYLE_ORIGINAL_NAME : - new_style =STYLE_DATE_POST_TITLE - elif current_style ==STYLE_DATE_POST_TITLE : - new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING - elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : - new_style =STYLE_DATE_BASED - elif current_style ==STYLE_DATE_BASED : - new_style =STYLE_POST_ID # Change this line - elif current_style ==STYLE_POST_ID: # Add this block - new_style =STYLE_POST_TITLE - else : - self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').") - new_style =STYLE_POST_TITLE + url_text = self.link_input.text().strip() if self.link_input else "" + _, _, post_id = extract_post_info(url_text) + is_single_post = bool(post_id) + + current_style = self.manga_filename_style + new_style = "" + + if is_single_post: + # Cycle through a limited set of styles suitable for single posts + if current_style == STYLE_POST_TITLE: + new_style = STYLE_DATE_POST_TITLE + elif current_style == STYLE_DATE_POST_TITLE: + new_style = STYLE_ORIGINAL_NAME + elif current_style == STYLE_ORIGINAL_NAME: + new_style = STYLE_POST_ID + elif current_style == STYLE_POST_ID: + new_style = STYLE_POST_TITLE + else: # Fallback for any other style + new_style = STYLE_POST_TITLE + else: + # Original cycling logic for creator feeds + if current_style ==STYLE_POST_TITLE : + new_style =STYLE_ORIGINAL_NAME + elif current_style ==STYLE_ORIGINAL_NAME : + new_style =STYLE_DATE_POST_TITLE + elif current_style ==STYLE_DATE_POST_TITLE : + new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING + elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : + new_style =STYLE_DATE_BASED + elif current_style ==STYLE_DATE_BASED : + new_style =STYLE_POST_ID + elif current_style ==STYLE_POST_ID: + new_style =STYLE_POST_TITLE + else : + self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').") + new_style =STYLE_POST_TITLE self .manga_filename_style =new_style self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style ) @@ -2643,16 +2689,32 @@ class DownloaderApp (QWidget ): url_text =self .link_input .text ().strip ()if self .link_input else "" _ ,_ ,post_id =extract_post_info (url_text ) + # --- START: MODIFIED LOGIC --- is_creator_feed =not post_id if url_text else False + is_single_post = bool(post_id) is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False + # If the download queue contains items selected from the popup, treat it as a single-post context for UI purposes. + if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue): + is_single_post = True + + # Allow Manga Mode checkbox for any valid URL (creator or single post) or if single posts are queued. + can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on + if self .manga_mode_checkbox : - self .manga_mode_checkbox .setEnabled (is_creator_feed and not is_favorite_mode_on ) - if not is_creator_feed and self .manga_mode_checkbox .isChecked (): + self .manga_mode_checkbox .setEnabled (can_enable_manga_checkbox) + if not can_enable_manga_checkbox and self .manga_mode_checkbox .isChecked (): self .manga_mode_checkbox .setChecked (False ) checked =self .manga_mode_checkbox .isChecked () - manga_mode_effectively_on =is_creator_feed and checked + manga_mode_effectively_on = can_enable_manga_checkbox and checked + + # If it's a single post context, prevent sequential styles from being selected as they don't apply. + sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] + if is_single_post and self.manga_filename_style in sequential_styles: + self.manga_filename_style = STYLE_POST_TITLE # Default to a safe, non-sequential style + self._update_manga_filename_style_button_text() + # --- END: MODIFIED LOGIC --- if self .manga_rename_toggle_button : self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )) @@ -2762,7 +2824,9 @@ 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, is_continuation=False): + def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None): + self.finish_lock = threading.Lock() + self.is_finishing = False if self.active_update_profile: if not self.new_posts_for_update: return self._check_for_updates() @@ -2888,17 +2952,30 @@ class DownloaderApp (QWidget ): self.cancellation_message_logged_this_session = False service, user_id, post_id_from_url = extract_post_info(api_url) + + # --- START: MODIFIED SECTION --- + # This check is now smarter. It only triggers the error if the item from the queue + # was supposed to be a post ('single_post_from_popup', etc.) but couldn't be parsed. + if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue: + self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}") + self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.") + self.download_finished( + total_downloaded=0, + total_skipped=1, + cancelled_by_user=False, + kept_original_names_list=[] + ) + return False + # --- END: MODIFIED SECTION --- + if not service or not user_id: QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.") return False - # Read the setting at the start of the download self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) - profile_processed_ids = set() # Default to an empty set - + creator_profile_data = {} if self.save_creator_json_enabled_this_session: - # --- CREATOR PROFILE LOGIC --- creator_name_for_profile = None if self.is_processing_favorites_queue and self.current_processing_favorite_item_info: creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder') @@ -2912,7 +2989,6 @@ class DownloaderApp (QWidget ): creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path) - # Get all current UI settings and add them to the profile current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run) creator_profile_data['settings'] = current_settings @@ -2924,10 +3000,17 @@ class DownloaderApp (QWidget ): self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path) self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.") - profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) - # --- END OF PROFILE LOGIC --- + profile_processed_ids = set() + + if self.active_update_profile: + self.log_signal.emit(" Update session active: Loading existing processed post IDs to find new content.") + profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) + + elif not is_restore: + self.log_signal.emit(" Fresh download session: Clearing previous post history for this creator to re-download all.") + if 'processed_post_ids' in creator_profile_data: + creator_profile_data['processed_post_ids'] = [] - # The rest of this logic runs regardless, but uses the profile data if it was loaded session_processed_ids = set(processed_post_ids_for_restore) combined_processed_ids = session_processed_ids.union(profile_processed_ids) processed_post_ids_for_this_run = list(combined_processed_ids) @@ -3055,7 +3138,7 @@ class DownloaderApp (QWidget ): elif backend_filter_mode == 'audio': current_mode_log_text = "Audio Download" current_char_filter_scope = self.get_char_filter_scope() - manga_mode = manga_mode_is_checked and not post_id_from_url + manga_mode = manga_mode_is_checked manga_date_prefix_text = "" if manga_mode and (self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_ORIGINAL_NAME) and hasattr(self, 'manga_date_prefix_input'): @@ -3478,6 +3561,7 @@ class DownloaderApp (QWidget ): if hasattr (self .download_thread ,'file_progress_signal'):self .download_thread .file_progress_signal .connect (self .update_file_progress_display ) if hasattr (self .download_thread ,'missed_character_post_signal'): self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post ) + if hasattr(self.download_thread, 'overall_progress_signal'): self.download_thread.overall_progress_signal.connect(self.update_progress_display) if hasattr (self .download_thread ,'retryable_file_failed_signal'): if hasattr (self .download_thread ,'file_successfully_downloaded_signal'): @@ -3862,7 +3946,12 @@ class DownloaderApp (QWidget ): if not filepath.lower().endswith('.pdf'): filepath += '.pdf' - font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + base_path = sys._MEIPASS + else: + base_path = self.app_base_dir + + font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf') self.log_signal.emit(" Sorting collected posts by date (oldest first)...") sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z')) @@ -4182,9 +4271,12 @@ class DownloaderApp (QWidget ): # Update UI to "Cancelling" state self.pause_btn.setEnabled(False) self.cancel_btn.setEnabled(False) + + if hasattr(self, 'reset_button'): + self.reset_button.setEnabled(False) + self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait.")) - # Signal all active components to stop if self.download_thread and self.download_thread.isRunning(): self.download_thread.requestInterruption() self.log_signal.emit(" Signaled single download thread to interrupt.") @@ -4199,22 +4291,27 @@ class DownloaderApp (QWidget ): def _get_domain_for_service (self ,service_name :str )->str : """Determines the base domain for a given service.""" if not isinstance (service_name ,str ): - return "kemono.su" + return "kemono.cr" service_lower =service_name .lower () coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'} if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']: - return "coomer.su" - return "kemono.su" - + return "coomer.st" + return "kemono.cr" def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None): - if self.is_finishing: + if not self.finish_lock.acquire(blocking=False): return - self.is_finishing = True try: + if self.is_finishing: + return + self.is_finishing = True + if cancelled_by_user: self.log_signal.emit("✅ Cancellation complete. Resetting UI.") + self._clear_session_file() + self.interrupted_session_data = None + self.is_restore_pending = False current_url = self.link_input.text() current_dir = self.dir_input.text() self._perform_soft_ui_reset(preserve_url=current_url, preserve_dir=current_dir) @@ -4222,13 +4319,14 @@ class DownloaderApp (QWidget ): self.file_progress_label.setText("") if self.pause_event: self.pause_event.clear() self.is_paused = False - return # Exit after handling cancellation + return self.log_signal.emit("🏁 Download of current item complete.") if self.is_processing_favorites_queue and self.favorite_download_queue: self.log_signal.emit("✅ Item finished. Processing next in queue...") - self.is_finishing = False # Allow the next item in queue to start + self.is_finishing = False + self.finish_lock.release() self._process_next_favorite_download() return @@ -4317,6 +4415,7 @@ class DownloaderApp (QWidget ): QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) if reply == QMessageBox.Yes: self.is_finishing = False # Allow retry session to start + self.finish_lock.release() # Release lock for the retry session self._start_failed_files_retry_session() return # Exit to allow retry session to run else: @@ -4334,7 +4433,7 @@ class DownloaderApp (QWidget ): self.cancellation_message_logged_this_session = False self.active_update_profile = None finally: - self.is_finishing = False + pass def _handle_keep_duplicates_toggled(self, checked): """Shows the duplicate handling dialog when the checkbox is checked.""" @@ -5164,6 +5263,31 @@ class DownloaderApp (QWidget ): if hasattr(self, 'link_input'): self.last_link_input_text_for_queue_sync = self.link_input.text() + # --- START: MODIFIED LOGIC --- + # Manually trigger the UI update now that the queue is populated and the dialog is closed. + self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False) + # --- END: MODIFIED LOGIC --- + + def _load_saved_cookie_settings(self): + """Loads and applies saved cookie settings on startup.""" + try: + use_cookie_saved = self.settings.value(USE_COOKIE_KEY, False, type=bool) + cookie_content_saved = self.settings.value(COOKIE_TEXT_KEY, "", type=str) + + if use_cookie_saved and cookie_content_saved: + self.use_cookie_checkbox.setChecked(True) + self.cookie_text_input.setText(cookie_content_saved) + + # Check if the saved content is a file path and update UI accordingly + if os.path.exists(cookie_content_saved): + self.selected_cookie_filepath = cookie_content_saved + self.cookie_text_input.setReadOnly(True) + self._update_cookie_input_placeholders_and_tooltips() + + self.log_signal.emit(f"ℹ️ Loaded saved cookie settings.") + except Exception as e: + self.log_signal.emit(f"⚠️ Could not load saved cookie settings: {e}") + def _show_favorite_artists_dialog (self ): if self ._is_download_active ()or self .is_processing_favorites_queue : QMessageBox .warning (self ,"Busy","Another download operation is already in progress.") @@ -5285,7 +5409,7 @@ class DownloaderApp (QWidget ): else : self .log_signal .emit ("ℹ️ Favorite posts selection cancelled.") - def _process_next_favorite_download (self ): + def _process_next_favorite_download(self): if self.favorite_download_queue and not self.is_processing_favorites_queue: manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False @@ -5330,33 +5454,43 @@ class DownloaderApp (QWidget ): next_url =self .current_processing_favorite_item_info ['url'] item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item') - item_type =self .current_processing_favorite_item_info .get ('type','artist') - self .log_signal .emit (f"▶️ Processing next favorite from queue: '{item_display_name }' ({next_url })") + # --- START: MODIFIED SECTION --- + # Get the type of item from the queue to help start_download make smarter decisions. + item_type = self.current_processing_favorite_item_info.get('type', 'artist') + self.log_signal.emit(f"▶️ Processing next favorite from queue ({item_type}): '{item_display_name}' ({next_url})") - override_dir =None - item_scope =self .current_processing_favorite_item_info .get ('scope_from_popup') - if item_scope is None : - item_scope =self .favorite_download_scope + override_dir = None + item_scope = self.current_processing_favorite_item_info.get('scope_from_popup') + if item_scope is None: + item_scope = self.favorite_download_scope - main_download_dir =self .dir_input .text ().strip () + main_download_dir = self.dir_input.text().strip() - should_create_artist_folder =False - if item_type =='creator_popup_selection'and item_scope ==EmptyPopupDialog .SCOPE_CREATORS : - should_create_artist_folder =True - elif item_type !='creator_popup_selection'and self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS : - should_create_artist_folder =True + should_create_artist_folder = False + if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS: + should_create_artist_folder = True + elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS: + should_create_artist_folder = True - if should_create_artist_folder and main_download_dir : - folder_name_key =self .current_processing_favorite_item_info .get ('name_for_folder','Unknown_Folder') - item_specific_folder_name =clean_folder_name (folder_name_key ) - override_dir =os .path .normpath (os .path .join (main_download_dir ,item_specific_folder_name )) - self .log_signal .emit (f" Scope requires artist folder. Target directory: '{override_dir }'") + if should_create_artist_folder and main_download_dir: + folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder') + item_specific_folder_name = clean_folder_name(folder_name_key) + override_dir = os.path.normpath(os.path.join(main_download_dir, item_specific_folder_name)) + self.log_signal.emit(f" Scope requires artist folder. Target directory: '{override_dir}'") - success_starting_download =self .start_download (direct_api_url =next_url ,override_output_dir =override_dir, is_continuation=True ) + # Pass the item_type to the start_download function + success_starting_download = self.start_download( + direct_api_url=next_url, + override_output_dir=override_dir, + is_continuation=True, + item_type_from_queue=item_type + ) + # --- END: MODIFIED SECTION --- - if not success_starting_download : - self .log_signal .emit (f"⚠️ Failed to initiate download for '{item_display_name }'. Skipping this item in queue.") - self .download_finished (total_downloaded =0 ,total_skipped =1 ,cancelled_by_user =True ,kept_original_names_list =[]) + if not success_starting_download: + self.log_signal.emit(f"⚠️ Failed to initiate download for '{item_display_name}'. Skipping and moving to the next item in queue.") + # Use a QTimer to avoid deep recursion and correctly move to the next item. + QTimer.singleShot(100, self._process_next_favorite_download) class ExternalLinkDownloadThread (QThread ): """A QThread to handle downloading multiple external links sequentially.""" diff --git a/src/utils/network_utils.py b/src/utils/network_utils.py index fb5e882..bc98e06 100644 --- a/src/utils/network_utils.py +++ b/src/utils/network_utils.py @@ -196,10 +196,9 @@ def get_link_platform(url): if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x' if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite' if 'pixiv.net' in domain: return 'pixiv' - if 'kemono.su' in domain or 'kemono.party' in domain: return 'kemono' - if 'coomer.su' in domain or 'coomer.party' in domain: return 'coomer' + if 'kemono.su' in domain or 'kemono.party' in domain or 'kemono.cr' in domain: return 'kemono' + if 'coomer.su' in domain or 'coomer.party' in domain or 'coomer.st' in domain: return 'coomer' - # Fallback to a generic name for other domains parts = domain.split('.') if len(parts) >= 2: return parts[-2]