From fbdae61b808172554bccc4f446806f3fefafc61c Mon Sep 17 00:00:00 2001 From: Yuvi9587 <114073886+Yuvi9587@users.noreply.github.com> Date: Sat, 19 Jul 2025 03:28:32 -0700 Subject: [PATCH] Commit --- src/core/api_client.py | 19 --- src/core/manager.py | 49 ++---- src/core/workers.py | 139 +++++++++------- src/ui/assets.py | 3 - src/ui/dialogs/ConfirmAddAllDialog.py | 20 --- .../dialogs/DownloadExtractedLinksDialog.py | 28 ---- src/ui/dialogs/DownloadHistoryDialog.py | 7 - src/ui/dialogs/ErrorFilesDialog.py | 4 + src/ui/dialogs/FavoritePostsDialog.py | 6 - src/ui/dialogs/HelpGuideDialog.py | 5 - src/ui/dialogs/KeepDuplicatesDialog.py | 15 -- src/ui/dialogs/KnownNamesFilterDialog.py | 8 +- src/ui/dialogs/SinglePDF.py | 107 ++++++++----- src/ui/dialogs/TourDialog.py | 11 -- src/ui/main_window.py | 149 +++--------------- 15 files changed, 194 insertions(+), 376 deletions(-) diff --git a/src/core/api_client.py b/src/core/api_client.py index 2d56524..e4a7490 100644 --- a/src/core/api_client.py +++ b/src/core/api_client.py @@ -3,8 +3,6 @@ import traceback from urllib.parse import urlparse import json # Ensure json is imported import requests - -# (Keep the rest of your imports) from ..utils.network_utils import extract_post_info, prepare_cookies_for_request from ..config.constants import ( STYLE_DATE_POST_TITLE @@ -25,9 +23,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev raise RuntimeError("Fetch operation cancelled by user while paused.") time.sleep(0.5) logger(" Post fetching resumed.") - - # --- MODIFICATION: Added `fields` to the URL to request only metadata --- - # This prevents the large 'content' field from being included in the list, avoiding timeouts. fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags" paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}' @@ -44,7 +39,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev logger(log_message) try: - # We can now remove the streaming logic as the response will be small and fast. response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) response.raise_for_status() return response.json() @@ -80,7 +74,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}" logger(f" Fetching full content for post ID {post_id}...") try: - # Use streaming here as a precaution for single posts that are still very large. with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response: response.raise_for_status() response_body = b"" @@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge response_body += chunk full_post_data = json.loads(response_body) - # The API sometimes wraps the post in a list, handle that. if isinstance(full_post_data, list) and full_post_data: return full_post_data[0] return full_post_data @@ -134,14 +126,10 @@ def download_from_api( 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' } - - # --- ADD THIS BLOCK --- - # Ensure processed_post_ids is a set for fast lookups if processed_post_ids is None: processed_post_ids = set() else: processed_post_ids = set(processed_post_ids) - # --- END OF ADDITION --- service, user_id, target_post_id = extract_post_info(api_url_input) @@ -158,11 +146,9 @@ def download_from_api( if use_cookie and app_base_dir: cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain) if target_post_id: - # --- ADD THIS CHECK FOR RESTORE --- if target_post_id in processed_post_ids: logger(f" Skipping already processed target post ID: {target_post_id}") return - # --- END OF ADDITION --- direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}" logger(f" Attempting direct fetch for target post: {direct_post_api_url}") try: @@ -248,14 +234,12 @@ def download_from_api( break if cancellation_event and cancellation_event.is_set(): return if all_posts_for_manga_mode: - # --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE --- if processed_post_ids: original_count = len(all_posts_for_manga_mode) all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids] skipped_count = original_count - len(all_posts_for_manga_mode) if skipped_count > 0: logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.") - # --- END OF ADDITION --- logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...") def sort_key_tuple(post): @@ -326,15 +310,12 @@ def download_from_api( logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}") traceback.print_exc() break - - # --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE --- if processed_post_ids: original_count = len(posts_batch) posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids] skipped_count = original_count - len(posts_batch) if skipped_count > 0: logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.") - # --- END OF ADDITION --- if not posts_batch: if target_post_id and not processed_target_post_flag: diff --git a/src/core/manager.py b/src/core/manager.py index 96e5170..a4f02cb 100644 --- a/src/core/manager.py +++ b/src/core/manager.py @@ -1,13 +1,9 @@ -# --- Standard Library Imports --- import threading import time import os import json import traceback from concurrent.futures import ThreadPoolExecutor, as_completed, Future - -# --- Local Application Imports --- -# These imports reflect the new, organized project structure. from .api_client import download_from_api from .workers import PostProcessorWorker, DownloadThread from ..config.constants import ( @@ -36,8 +32,6 @@ class DownloadManager: self.progress_queue = progress_queue self.thread_pool = None self.active_futures = [] - - # --- Session State --- self.cancellation_event = threading.Event() self.pause_event = threading.Event() self.is_running = False @@ -64,8 +58,6 @@ class DownloadManager: if self.is_running: self._log("❌ Cannot start a new session: A session is already in progress.") return - - # --- Reset state for the new session --- self.is_running = True self.cancellation_event.clear() self.pause_event.clear() @@ -75,8 +67,6 @@ class DownloadManager: self.total_downloads = 0 self.total_skips = 0 self.all_kept_original_filenames = [] - - # --- Decide execution strategy (multi-threaded vs. single-threaded) --- 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] @@ -84,7 +74,6 @@ class DownloadManager: should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential if should_use_multithreading_for_posts: - # Start a separate thread to manage fetching and queuing to the thread pool fetcher_thread = threading.Thread( target=self._fetch_and_queue_posts_for_pool, args=(config, restore_data), @@ -92,16 +81,11 @@ class DownloadManager: ) fetcher_thread.start() else: - # For single posts or sequential manga mode, use a single worker thread - # which is simpler and ensures order. self._start_single_threaded_session(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...") - - # The original DownloadThread is now a pure Python thread, not a QThread. - # We run its `run` method in a standard Python thread. self.worker_thread = threading.Thread( target=self._run_single_worker, args=(config,), @@ -112,7 +96,6 @@ class DownloadManager: def _run_single_worker(self, config): """Target function for the single-worker thread.""" try: - # Pass the queue directly to the worker for it to send updates worker = DownloadThread(config, self.progress_queue) worker.run() # This is the main blocking call for this thread except Exception as e: @@ -129,9 +112,6 @@ class DownloadManager: try: num_workers = min(config.get('num_threads', 4), MAX_THREADS) self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') - - # Fetch posts - # In a real implementation, this would call `api_client.download_from_api` if restore_data: all_posts = restore_data['all_posts_data'] processed_ids = set(restore_data['processed_post_ids']) @@ -149,12 +129,9 @@ class DownloadManager: if not posts_to_process: self._log("✅ No new posts to process.") return - - # Submit tasks to the pool for post_data in posts_to_process: if self.cancellation_event.is_set(): break - # Each PostProcessorWorker gets the queue to send its own updates worker = PostProcessorWorker(post_data, config, self.progress_queue) future = self.thread_pool.submit(worker.process) future.add_done_callback(self._handle_future_result) @@ -164,27 +141,32 @@ class DownloadManager: self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}") self._log(traceback.format_exc()) finally: - # Wait for all submitted tasks to complete before shutting down if self.thread_pool: self.thread_pool.shutdown(wait=True) self.is_running = False self._log("🏁 All processing tasks have completed.") - # Emit final signal self.progress_queue.put({ '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 = [] - # This generator yields batches of posts post_generator = download_from_api( api_url_input=config['api_url'], logger=self._log, - # ... pass other relevant config keys ... + 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 + 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) @@ -203,14 +185,11 @@ class DownloadManager: self.total_skips += 1 else: result = future.result() - # Unpack result tuple from the worker (dl_count, skip_count, kept_originals, retryable, permanent, history) = result self.total_downloads += dl_count self.total_skips += skip_count self.all_kept_original_filenames.extend(kept_originals) - - # Queue up results for UI to handle if retryable: self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)}) if permanent: @@ -221,8 +200,6 @@ class DownloadManager: except Exception as e: self._log(f"❌ Worker task resulted in an exception: {e}") self.total_skips += 1 # Count errored posts as skipped - - # Update overall progress self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) def cancel_session(self): @@ -231,11 +208,7 @@ class DownloadManager: return self._log("⚠️ Cancellation requested by user...") self.cancellation_event.set() - - # For single thread mode, the worker checks the event - # For multi-thread mode, shut down the pool if self.thread_pool: - # Don't wait, just cancel pending futures and let the fetcher thread exit self.thread_pool.shutdown(wait=False, cancel_futures=True) self.is_running = False diff --git a/src/core/workers.py b/src/core/workers.py index eb6c907..1e2d182 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -1,4 +1,3 @@ -# --- Standard Library Imports --- import os import queue import re @@ -15,15 +14,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, from io import BytesIO from urllib .parse import urlparse import requests -# --- Third-Party Library Imports --- try: from PIL import Image except ImportError: Image = None -# try: from fpdf import FPDF - # Add a simple class to handle the header/footer for stories class PDF(FPDF): def header(self): pass # No header @@ -39,16 +35,12 @@ try: from docx import Document except ImportError: Document = None - -# --- PyQt5 Imports --- from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess -# --- Local Application Imports --- from .api_client import download_from_api, fetch_post_comments from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE from ..services.drive_downloader import ( download_mega_file, download_gdrive_file, download_dropbox_file ) -# Corrected Imports: from ..utils.file_utils import ( is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES, clean_filename, clean_folder_name @@ -567,10 +559,8 @@ class PostProcessorWorker: with self.downloaded_hash_counts_lock: current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0) - # Default to not skipping decision_to_skip = False - # Apply logic based on mode if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH: if current_count >= 1: decision_to_skip = True @@ -581,12 +571,10 @@ class PostProcessorWorker: decision_to_skip = True self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.") - # If we are NOT skipping this file, we MUST increment the count. if not decision_to_skip: self.downloaded_hash_counts[calculated_file_hash] = current_count + 1 should_skip = decision_to_skip - # --- End of Final Corrected Logic --- if should_skip: if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): @@ -678,9 +666,14 @@ class PostProcessorWorker: else: self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.") details_for_failure = { - 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, - 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, - 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post + 'file_info': file_info, + 'target_folder_path': target_folder_path, + 'headers': headers, + 'original_post_id_for_log': original_post_id_for_log, + 'post_title': post_title, + 'file_index_in_post': file_index_in_post, + 'num_files_in_this_post': num_files_in_this_post, + 'forced_filename_override': filename_to_save_in_main_path } if is_permanent_error: return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, details_for_failure @@ -1040,7 +1033,9 @@ class PostProcessorWorker: return result_tuple raw_text_content = "" + comments_data = [] final_post_data = post_data + if self.text_only_scope == 'content' and 'content' not in final_post_data: self.logger(f" Post {post_id} is missing 'content' field, fetching full data...") parsed_url = urlparse(self.api_url_input) @@ -1050,6 +1045,7 @@ class PostProcessorWorker: full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies) if full_data: final_post_data = full_data + if self.text_only_scope == 'content': raw_text_content = final_post_data.get('content', '') elif self.text_only_scope == 'comments': @@ -1060,46 +1056,46 @@ class PostProcessorWorker: if comments_data: comment_texts = [] for comment in comments_data: - user = comment.get('user', {}).get('name', 'Unknown User') - timestamp = comment.get('updated', 'No Date') + user = comment.get('commenter_name', 'Unknown User') + timestamp = comment.get('published', 'No Date') body = strip_html_tags(comment.get('content', '')) comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n") raw_text_content = "\n".join(comment_texts) + else: + raw_text_content = "" except Exception as e: self.logger(f" ❌ Error fetching comments for text-only mode: {e}") - if not raw_text_content or not raw_text_content.strip(): + cleaned_text = "" + if self.text_only_scope == 'content': + if not raw_text_content: + cleaned_text = "" + else: + text_with_newlines = re.sub(r'(?i)

|', '\n', raw_text_content) + just_text = re.sub(r'<.*?>', '', text_with_newlines) + cleaned_text = html.unescape(just_text).strip() + else: + cleaned_text = raw_text_content + + cleaned_text = cleaned_text.replace('…', '...') + + if not cleaned_text.strip(): self.logger(" -> Skip Saving Text: No content/comments found or fetched.") result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) return result_tuple - paragraph_pattern = re.compile(r'(.*?)

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

tags found. Falling back to basic HTML cleaning for the whole block.") - text_with_br = re.sub(r'', '\n', raw_text_content, flags=re.IGNORECASE) - cleaned_text = re.sub(r'<.*?>', '', text_with_br) - else: - cleaned_paragraphs_list = [] - for p_content in html_paragraphs: - p_with_br = re.sub(r'', '\n', p_content, flags=re.IGNORECASE) - p_cleaned = re.sub(r'<.*?>', '', p_with_br) - p_final = html.unescape(p_cleaned).strip() - if p_final: - cleaned_paragraphs_list.append(p_final) - cleaned_text = '\n\n'.join(cleaned_paragraphs_list) - cleaned_text = cleaned_text.replace('…', '...') - if self.single_pdf_mode: - if not cleaned_text: - result_tuple = (0, 0, [], [], [], None, None) - return result_tuple content_data = { 'title': post_title, - 'content': cleaned_text, 'published': self.post.get('published') or self.post.get('added') } + if self.text_only_scope == 'comments': + if not comments_data: return (0, 0, [], [], [], None, None) + content_data['comments'] = comments_data + else: + if not cleaned_text.strip(): return (0, 0, [], [], [], None, None) + content_data['content'] = cleaned_text + temp_dir = os.path.join(self.app_base_dir, "appdata") os.makedirs(temp_dir, exist_ok=True) temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json" @@ -1107,13 +1103,11 @@ class PostProcessorWorker: try: with open(temp_filepath, 'w', encoding='utf-8') as f: json.dump(content_data, f, indent=2) - self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.") - result_tuple = (0, 0, [], [], [], None, temp_filepath) - return result_tuple + self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.") + return (0, 0, [], [], [], None, temp_filepath) except Exception as e: self.logger(f" ❌ Failed to write temporary file for single PDF: {e}") - result_tuple = (0, 0, [], [], [], None, None) - return result_tuple + return (0, 0, [], [], [], None, None) else: file_extension = self.text_export_format txt_filename = clean_filename(post_title) + f".{file_extension}" @@ -1125,27 +1119,63 @@ class PostProcessorWorker: while os.path.exists(final_save_path): final_save_path = f"{base}_{counter}{ext}" counter += 1 + if file_extension == 'pdf': if FPDF: - self.logger(f" Converting to PDF...") + self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...") pdf = PDF() 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') + try: if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") + if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}") pdf.add_font('DejaVu', '', font_path, uni=True) - pdf.set_font('DejaVu', '', 12) + pdf.add_font('DejaVu', 'B', bold_font_path, uni=True) + default_font_family = 'DejaVu' except Exception as font_error: self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") - pdf.set_font('Arial', '', 12) + default_font_family = 'Arial' + pdf.add_page() - pdf.multi_cell(0, 5, cleaned_text) + pdf.set_font(default_font_family, 'B', 16) + pdf.multi_cell(0, 10, post_title) + pdf.ln(10) + + if self.text_only_scope == 'comments': + if not comments_data: + self.logger(" -> Skip PDF Creation: No comments to process.") + return (0, num_potential_files_in_post, [], [], [], None, None) + for i, comment in enumerate(comments_data): + user = comment.get('commenter_name', 'Unknown User') + timestamp = comment.get('published', 'No Date') + body = strip_html_tags(comment.get('content', '')) + pdf.set_font(default_font_family, '', 10) + pdf.write(8, "Comment by: ") + pdf.set_font(default_font_family, 'B', 10) + pdf.write(8, user) + pdf.set_font(default_font_family, '', 10) + pdf.write(8, f" on {timestamp}") + pdf.ln(10) + pdf.set_font(default_font_family, '', 11) + pdf.multi_cell(0, 7, body) + if i < len(comments_data) - 1: + pdf.ln(5) + pdf.cell(0, 0, '', border='T') + pdf.ln(5) + else: + pdf.set_font(default_font_family, '', 12) + pdf.multi_cell(0, 7, cleaned_text) + pdf.output(final_save_path) else: self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") final_save_path = os.path.splitext(final_save_path)[0] + ".txt" with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) + elif file_extension == 'docx': if Document: self.logger(f" Converting to DOCX...") @@ -1156,12 +1186,15 @@ class PostProcessorWorker: self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.") final_save_path = os.path.splitext(final_save_path)[0] + ".txt" with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) - else: + + else: # TXT file with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) + self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'") result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None) return result_tuple + except Exception as e: self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}") result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) @@ -1263,7 +1296,6 @@ class PostProcessorWorker: if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH: unique_files_by_url = {} for file_info in all_files_from_post_api: - # Use the file URL as a unique key to avoid processing the same file multiple times file_url = file_info.get('url') if file_url and file_url not in unique_files_by_url: unique_files_by_url[file_url] = file_info @@ -1734,7 +1766,6 @@ class DownloadThread(QThread): worker_signals_obj = PostProcessorSignals() try: - # Connect signals worker_signals_obj.progress_signal.connect(self.progress_signal) worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal) worker_signals_obj.file_progress_signal.connect(self.file_progress_signal) @@ -1771,8 +1802,6 @@ class DownloadThread(QThread): was_process_cancelled = True break - # --- START OF FIX: Explicitly build the arguments dictionary --- - # This robustly maps all thread attributes to the correct worker parameters. worker_args = { 'post_data': individual_post_data, 'emitter': worker_signals_obj, @@ -1833,7 +1862,6 @@ class DownloadThread(QThread): 'single_pdf_mode': self.single_pdf_mode, 'project_root_dir': self.project_root_dir, } - # --- END OF FIX --- post_processing_worker = PostProcessorWorker(**worker_args) @@ -1860,6 +1888,7 @@ class DownloadThread(QThread): if not was_process_cancelled and not self.isInterruptionRequested(): self.logger("✅ All posts processed or end of content reached by DownloadThread.") + except Exception as main_thread_err: self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}") traceback.print_exc() diff --git a/src/ui/assets.py b/src/ui/assets.py index ac3ec13..fe2ec76 100644 --- a/src/ui/assets.py +++ b/src/ui/assets.py @@ -1,8 +1,5 @@ -# --- Standard Library Imports --- import os import sys - -# --- PyQt5 Imports --- from PyQt5.QtGui import QIcon _app_icon_cache = None diff --git a/src/ui/dialogs/ConfirmAddAllDialog.py b/src/ui/dialogs/ConfirmAddAllDialog.py index be91480..8994e94 100644 --- a/src/ui/dialogs/ConfirmAddAllDialog.py +++ b/src/ui/dialogs/ConfirmAddAllDialog.py @@ -1,18 +1,10 @@ -# --- PyQt5 Imports --- from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout ) - -# --- Local Application Imports --- -# This assumes the new project structure is in place. from ...i18n.translator import get_translation -# get_app_icon_object is defined in the main window module in this refactoring plan. from ..main_window import get_app_icon_object - -# --- Constants for Dialog Choices --- -# These were moved from main.py to be self-contained within this module's context. CONFIRM_ADD_ALL_ACCEPTED = 1 CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 @@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog): self.parent_app = parent_app self.setModal(True) self.new_filter_objects_list = new_filter_objects_list - # Default choice if the dialog is closed without a button press self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD - - # --- Basic Window Setup --- app_icon = get_app_icon_object() if app_icon and not app_icon.isNull(): self.setWindowIcon(app_icon) - - # Set window size dynamically screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 scale_factor = screen_height / 768.0 base_min_w, base_min_h = 480, 350 scaled_min_w = int(base_min_w * scale_factor) scaled_min_h = int(base_min_h * scale_factor) self.setMinimumSize(scaled_min_w, scaled_min_h) - - # --- Initialize UI and Apply Theming --- self._init_ui() self._retranslate_ui() self._apply_theme() @@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog): self.names_list_widget = QListWidget() self._populate_list() main_layout.addWidget(self.names_list_widget) - - # --- Selection Buttons --- selection_buttons_layout = QHBoxLayout() self.select_all_button = QPushButton() self.select_all_button.clicked.connect(self._select_all_items) @@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog): selection_buttons_layout.addWidget(self.deselect_all_button) selection_buttons_layout.addStretch() main_layout.addLayout(selection_buttons_layout) - - # --- Action Buttons --- buttons_layout = QHBoxLayout() self.add_selected_button = QPushButton() self.add_selected_button.clicked.connect(self._accept_add_selected) @@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog): sensible default if no items are selected but the "Add" button is clicked. """ super().exec_() - # If the user clicked "Add Selected" but didn't select any items, treat it as skipping. if isinstance(self.user_choice, list) and not self.user_choice: return CONFIRM_ADD_ALL_SKIP_ADDING return self.user_choice diff --git a/src/ui/dialogs/DownloadExtractedLinksDialog.py b/src/ui/dialogs/DownloadExtractedLinksDialog.py index b886659..bcc2a69 100644 --- a/src/ui/dialogs/DownloadExtractedLinksDialog.py +++ b/src/ui/dialogs/DownloadExtractedLinksDialog.py @@ -1,14 +1,9 @@ -# --- Standard Library Imports --- from collections import defaultdict - -# --- PyQt5 Imports --- from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView ) - -# --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object from ...utils.resolution import get_dark_theme @@ -18,8 +13,6 @@ class DownloadExtractedLinksDialog(QDialog): A dialog to select and initiate the download for extracted, supported links from external cloud services like Mega, Google Drive, and Dropbox. """ - - # Signal emitted with a list of selected link information dictionaries download_requested = pyqtSignal(list) def __init__(self, links_data, parent_app, parent=None): @@ -34,23 +27,13 @@ class DownloadExtractedLinksDialog(QDialog): super().__init__(parent) self.links_data = links_data self.parent_app = parent_app - - # --- Basic Window Setup --- app_icon = get_app_icon_object() if not app_icon.isNull(): self.setWindowIcon(app_icon) - - # --- START OF FIX --- - # Get the user-defined scale factor from the parent application. scale_factor = getattr(self.parent_app, 'scale_factor', 1.0) - - # Define base dimensions and apply the correct scale factor. base_width, base_height = 600, 450 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1)) - # --- END OF FIX --- - - # --- Initialize UI and Apply Theming --- self._init_ui() self._retranslate_ui() self._apply_theme() @@ -68,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog): self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection) self._populate_list() layout.addWidget(self.links_list_widget) - - # --- Control Buttons --- button_layout = QHBoxLayout() self.select_all_button = QPushButton() self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked)) @@ -100,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog): sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower()) for post_title_key in sorted_post_titles: - # Add a non-selectable header for each post header_item = QListWidgetItem(f"{post_title_key}") header_item.setFlags(Qt.NoItemFlags) font = header_item.font() @@ -108,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog): font.setPointSize(font.pointSize() + 1) header_item.setFont(font) self.links_list_widget.addItem(header_item) - - # Add checkable items for each link within that post for link_info_data in grouped_links[post_title_key]: platform_display = link_info_data.get('platform', 'unknown').upper() display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})" @@ -139,19 +117,13 @@ class DownloadExtractedLinksDialog(QDialog): is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark" if is_dark_theme: - # Get the scale factor from the parent app scale = getattr(self.parent_app, 'scale_factor', 1) - # Call the imported function with the correct scale self.setStyleSheet(get_dark_theme(scale)) else: - # Explicitly set a blank stylesheet for light mode self.setStyleSheet("") - - # Set header text color based on theme header_color = Qt.cyan if is_dark_theme else Qt.blue for i in range(self.links_list_widget.count()): item = self.links_list_widget.item(i) - # Headers are not checkable (they have no checkable flag) if not item.flags() & Qt.ItemIsUserCheckable: item.setForeground(header_color) diff --git a/src/ui/dialogs/DownloadHistoryDialog.py b/src/ui/dialogs/DownloadHistoryDialog.py index 7855fe8..68e83a6 100644 --- a/src/ui/dialogs/DownloadHistoryDialog.py +++ b/src/ui/dialogs/DownloadHistoryDialog.py @@ -1,16 +1,12 @@ -# --- Standard Library Imports --- import os import time import json -# --- PyQt5 Imports --- from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea, QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox, QFileDialog, QMessageBox ) - -# --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object from ...utils.resolution import get_dark_theme @@ -25,17 +21,14 @@ class DownloadHistoryDialog (QDialog ): self .first_processed_entries =first_processed_entries self .setModal (True ) self._apply_theme() - # Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available creator_name_cache = getattr(parent_app, 'creator_name_cache', None) if creator_name_cache: - # Patch left pane (files) for entry in self.last_3_downloaded_entries: if not entry.get('creator_display_name'): service = entry.get('service', '').lower() user_id = str(entry.get('user_id', '')) key = (service, user_id) entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series')) - # Patch right pane (posts) for entry in self.first_processed_entries: if not entry.get('creator_name'): service = entry.get('service', '').lower() diff --git a/src/ui/dialogs/ErrorFilesDialog.py b/src/ui/dialogs/ErrorFilesDialog.py index 2fc6a47..899412c 100644 --- a/src/ui/dialogs/ErrorFilesDialog.py +++ b/src/ui/dialogs/ErrorFilesDialog.py @@ -42,11 +42,15 @@ class ErrorFilesDialog(QDialog): if app_icon and not app_icon.isNull(): self.setWindowIcon(app_icon) + # --- START OF FIX --- + # Get the user-defined scale factor from the parent application. scale_factor = getattr(self.parent_app, 'scale_factor', 1.0) + # Define base dimensions and apply the correct scale factor. base_width, base_height = 550, 400 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1)) + # --- END OF FIX --- # --- Initialize UI and Apply Theming --- self._init_ui() diff --git a/src/ui/dialogs/FavoritePostsDialog.py b/src/ui/dialogs/FavoritePostsDialog.py index df0210e..5decc0a 100644 --- a/src/ui/dialogs/FavoritePostsDialog.py +++ b/src/ui/dialogs/FavoritePostsDialog.py @@ -1,4 +1,3 @@ -# --- Standard Library Imports --- import html import os import sys @@ -8,8 +7,6 @@ import traceback import json import re from collections import defaultdict - -# --- Third-Party Library Imports --- import requests from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread from PyQt5.QtWidgets import ( @@ -17,12 +14,9 @@ from PyQt5.QtWidgets import ( QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar, QWidget, QCheckBox ) - -# --- Local Application Imports --- from ...i18n.translator import get_translation from ..assets import get_app_icon_object from ...utils.network_utils import prepare_cookies_for_request -# Corrected Import: Import CookieHelpDialog directly from its own module from .CookieHelpDialog import CookieHelpDialog from ...core.api_client import download_from_api from ...utils.resolution import get_dark_theme diff --git a/src/ui/dialogs/HelpGuideDialog.py b/src/ui/dialogs/HelpGuideDialog.py index b927371..7fb196a 100644 --- a/src/ui/dialogs/HelpGuideDialog.py +++ b/src/ui/dialogs/HelpGuideDialog.py @@ -1,16 +1,11 @@ -# --- Standard Library Imports --- import os import sys - -# --- PyQt5 Imports --- from PyQt5.QtCore import QUrl, QSize, Qt from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QStackedWidget, QScrollArea, QFrame, QWidget ) - -# --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object from ...utils.resolution import get_dark_theme diff --git a/src/ui/dialogs/KeepDuplicatesDialog.py b/src/ui/dialogs/KeepDuplicatesDialog.py index ec8bf7f..38fb8cd 100644 --- a/src/ui/dialogs/KeepDuplicatesDialog.py +++ b/src/ui/dialogs/KeepDuplicatesDialog.py @@ -1,13 +1,8 @@ -# KeepDuplicatesDialog.py - -# --- PyQt5 Imports --- from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QGroupBox, QRadioButton, QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit ) from PyQt5.QtGui import QIntValidator - -# --- Local Application Imports --- from ...i18n.translator import get_translation from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL @@ -25,8 +20,6 @@ class KeepDuplicatesDialog(QDialog): if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'): self.parent_app._apply_theme_to_widget(self) - - # Set the initial state based on current settings if current_mode == DUPLICATE_HANDLING_KEEP_ALL: self.radio_keep_everything.setChecked(True) self.limit_input.setText(str(current_limit) if current_limit > 0 else "") @@ -44,13 +37,9 @@ class KeepDuplicatesDialog(QDialog): options_group = QGroupBox() options_layout = QVBoxLayout(options_group) self.button_group = QButtonGroup(self) - - # --- Skip by Hash Option --- self.radio_skip_by_hash = QRadioButton() self.button_group.addButton(self.radio_skip_by_hash) options_layout.addWidget(self.radio_skip_by_hash) - - # --- Keep Everything Option with Limit Input --- keep_everything_layout = QHBoxLayout() self.radio_keep_everything = QRadioButton() self.button_group.addButton(self.radio_keep_everything) @@ -66,8 +55,6 @@ class KeepDuplicatesDialog(QDialog): options_layout.addLayout(keep_everything_layout) main_layout.addWidget(options_group) - - # --- OK and Cancel buttons --- button_layout = QHBoxLayout() self.ok_button = QPushButton() self.cancel_button = QPushButton() @@ -75,8 +62,6 @@ class KeepDuplicatesDialog(QDialog): button_layout.addWidget(self.ok_button) button_layout.addWidget(self.cancel_button) main_layout.addLayout(button_layout) - - # --- Connections --- self.ok_button.clicked.connect(self.accept) self.cancel_button.clicked.connect(self.reject) self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled) diff --git a/src/ui/dialogs/KnownNamesFilterDialog.py b/src/ui/dialogs/KnownNamesFilterDialog.py index fd37b74..8b4ac6b 100644 --- a/src/ui/dialogs/KnownNamesFilterDialog.py +++ b/src/ui/dialogs/KnownNamesFilterDialog.py @@ -37,12 +37,16 @@ class KnownNamesFilterDialog(QDialog): if app_icon and not app_icon.isNull(): self.setWindowIcon(app_icon) - # Set window size dynamically - screen_geometry = QApplication.primaryScreen().availableGeometry() + # --- START OF FIX --- + # Get the user-defined scale factor from the parent application + # instead of calculating an independent one. scale_factor = getattr(self.parent_app, 'scale_factor', 1.0) + + # Define base size and apply the correct scale factor base_width, base_height = 460, 450 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1)) + # --- END OF FIX --- # --- Initialize UI and Apply Theming --- self._init_ui() diff --git a/src/ui/dialogs/SinglePDF.py b/src/ui/dialogs/SinglePDF.py index 41785e9..7bef00c 100644 --- a/src/ui/dialogs/SinglePDF.py +++ b/src/ui/dialogs/SinglePDF.py @@ -1,34 +1,33 @@ -# SinglePDF.py - import os +import re try: from fpdf import FPDF FPDF_AVAILABLE = True except ImportError: FPDF_AVAILABLE = False +def strip_html_tags(text): + if not text: + return "" + clean = re.compile('<.*?>') + return re.sub(clean, '', text) + class PDF(FPDF): """Custom PDF class to handle headers and footers.""" def header(self): - # No header - pass + pass def footer(self): - # Position at 1.5 cm from bottom self.set_y(-15) - self.set_font('DejaVu', '', 8) - # Page number + if self.font_family: + self.set_font(self.font_family, '', 8) + else: + self.set_font('Arial', '', 8) self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print): """ - Creates a single PDF from a list of post titles and content. - - Args: - posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys. - output_filename (str): The full path for the output PDF file. - font_path (str): Path to the DejaVuSans.ttf font file. - logger (function, optional): A function to log progress and errors. Defaults to print. + Creates a single, continuous PDF, correctly formatting both descriptions and comments. """ if not FPDF_AVAILABLE: logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2") @@ -39,34 +38,66 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge return False pdf = PDF() + default_font_family = 'DejaVu' + bold_font_path = "" + if font_path: + bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf") + try: - if not os.path.exists(font_path): - raise RuntimeError("Font file not found.") - pdf.add_font('DejaVu', '', font_path, uni=True) - pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant - except Exception as font_error: - logger(f" ⚠️ Could not load DejaVu font: {font_error}") - logger(" PDF may not support all characters. Falling back to default Arial font.") - pdf.set_font('Arial', '', 12) - pdf.set_font('Arial', 'B', 16) - - logger(f" Starting PDF creation with content from {len(posts_data)} posts...") - - for post in posts_data: - pdf.add_page() - # Post Title - pdf.set_font('DejaVu', 'B', 16) - - # vvv THIS LINE IS CORRECTED vvv - # We explicitly set align='L' and remove the incorrect positional arguments. - pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L') + if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") + if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}") - pdf.ln(5) # Add a little space after the title + pdf.add_font('DejaVu', '', font_path, uni=True) + pdf.add_font('DejaVu', 'B', bold_font_path, uni=True) + except Exception as font_error: + logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") + default_font_family = 'Arial' + + pdf.add_page() - # Post Content - pdf.set_font('DejaVu', '', 12) - pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) + logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...") + + for i, post in enumerate(posts_data): + if i > 0: + if 'content' in post: + pdf.add_page() + elif 'comments' in post: + pdf.ln(10) + pdf.cell(0, 0, '', border='T') + pdf.ln(10) + + pdf.set_font(default_font_family, 'B', 16) + pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L') + pdf.ln(5) + + if 'comments' in post and post['comments']: + comments_list = post['comments'] + for comment_index, comment in enumerate(comments_list): + user = comment.get('commenter_name', 'Unknown User') + timestamp = comment.get('published', 'No Date') + body = strip_html_tags(comment.get('content', '')) + + pdf.set_font(default_font_family, '', 10) + pdf.write(8, "Comment by: ") + if user is not None: + pdf.set_font(default_font_family, 'B', 10) + pdf.write(8, str(user)) + + pdf.set_font(default_font_family, '', 10) + pdf.write(8, f" on {timestamp}") + pdf.ln(10) + + pdf.set_font(default_font_family, '', 11) + pdf.multi_cell(0, 7, body) + + if comment_index < len(comments_list) - 1: + pdf.ln(3) + pdf.cell(w=0, h=0, border='T') + pdf.ln(3) + elif 'content' in post: + pdf.set_font(default_font_family, '', 12) + pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) try: pdf.output(output_filename) diff --git a/src/ui/dialogs/TourDialog.py b/src/ui/dialogs/TourDialog.py index b40aeea..c0c7fce 100644 --- a/src/ui/dialogs/TourDialog.py +++ b/src/ui/dialogs/TourDialog.py @@ -1,15 +1,10 @@ -# --- Standard Library Imports --- import os import sys - -# --- PyQt5 Imports --- from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox ) - -# --- Local Application Imports --- from ...i18n.translator import get_translation from ..main_window import get_app_icon_object from ...utils.resolution import get_dark_theme @@ -58,8 +53,6 @@ class TourDialog(QDialog): """ tour_finished_normally = pyqtSignal() tour_skipped = pyqtSignal() - - # Constants for QSettings CONFIG_APP_NAME_TOUR = "ApplicationTour" TOUR_SHOWN_KEY = "neverShowTourAgainV19" @@ -98,8 +91,6 @@ class TourDialog(QDialog): self.stacked_widget = QStackedWidget() main_layout.addWidget(self.stacked_widget, 1) - - # Load content for each step steps_content = [ ("tour_dialog_step1_title", "tour_dialog_step1_content"), ("tour_dialog_step2_title", "tour_dialog_step2_content"), @@ -120,8 +111,6 @@ class TourDialog(QDialog): self.stacked_widget.addWidget(step_widget) self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!")) - - # --- Bottom Controls --- bottom_controls_layout = QVBoxLayout() bottom_controls_layout.setContentsMargins(15, 10, 15, 15) bottom_controls_layout.setSpacing(12) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 3a1c69c..9d766de 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -1,4 +1,3 @@ -# --- Standard Library Imports --- import sys import os import time @@ -16,8 +15,6 @@ from collections import deque, defaultdict import threading from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError from urllib .parse import urlparse - -# --- PyQt5 Imports --- from PyQt5.QtGui import QIcon, QIntValidator, QDesktopServices from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, @@ -27,8 +24,6 @@ from PyQt5.QtWidgets import ( QMainWindow, QAction, QGridLayout, ) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker, QCoreApplication - -# --- Local Application Imports --- from ..services.drive_downloader import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file from ..core.workers import DownloadThread as BackendDownloadThread from ..core.workers import PostProcessorWorker @@ -137,8 +132,6 @@ class DownloaderApp (QWidget ): self.creator_name_cache = {} self.log_signal.emit(f"ℹ️ App base directory: {self.app_base_dir}") self.log_signal.emit(f"ℹ️ Persistent history file path set to: {self.persistent_history_file}") - - # --- The rest of your __init__ method continues from here --- self.last_downloaded_files_details = deque(maxlen=3) self.download_history_candidates = deque(maxlen=8) self.final_download_history_entries = [] @@ -225,7 +218,7 @@ class DownloaderApp (QWidget ): self.text_export_format = 'pdf' self.single_pdf_setting = False self.keep_duplicates_mode = DUPLICATE_HANDLING_HASH - self.keep_duplicates_limit = 0 # 0 means no limit + self.keep_duplicates_limit = 0 self.downloaded_hash_counts = defaultdict(int) self.downloaded_hash_counts_lock = threading.Lock() self.session_temp_files = [] @@ -288,8 +281,6 @@ class DownloaderApp (QWidget ): self.setStyleSheet(get_dark_theme(scale)) else: self.setStyleSheet("") - - # Prompt for restart msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Information) msg_box.setWindowTitle(self._tr("theme_change_title", "Theme Changed")) @@ -447,14 +438,14 @@ class DownloaderApp (QWidget ): self._load_ui_from_settings_dict(settings) self.is_restore_pending = True - self._update_button_states_and_connections() # Update buttons for restore state, UI remains editable + self._update_button_states_and_connections() def _clear_session_and_reset_ui(self): """Clears the session file and resets the UI to its default state.""" self._clear_session_file() self.interrupted_session_data = None self.is_restore_pending = False - self._update_button_states_and_connections() # Ensure buttons are updated to idle state + self._update_button_states_and_connections() self.reset_application_state() def _clear_session_file(self): @@ -489,7 +480,6 @@ class DownloaderApp (QWidget ): Updates the text and click connections of the main action buttons based on the current application state (downloading, paused, restore pending, idle). """ - # Disconnect all signals first to prevent multiple connections try: self.download_btn.clicked.disconnect() except TypeError: pass try: self.pause_btn.clicked.disconnect() @@ -500,7 +490,6 @@ class DownloaderApp (QWidget ): is_download_active = self._is_download_active() if self.is_restore_pending: - # State: Restore Pending self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) self.download_btn.setEnabled(True) self.download_btn.clicked.connect(self.start_download) @@ -511,14 +500,12 @@ class DownloaderApp (QWidget ): self.pause_btn.clicked.connect(self.restore_download) self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download.")) - # --- START: CORRECTED CANCEL BUTTON LOGIC --- self.cancel_btn.setText(self._tr("discard_session_button_text", "🗑️ Discard Session")) self.cancel_btn.setEnabled(True) self.cancel_btn.clicked.connect(self._clear_session_and_reset_ui) self.cancel_btn.setToolTip(self._tr("discard_session_tooltip", "Click to discard the interrupted session and reset the UI.")) elif is_download_active: - # State: Downloading / Paused self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) self.download_btn.setEnabled(False) # Cannot start new download while one is active @@ -532,7 +519,6 @@ class DownloaderApp (QWidget ): self.cancel_btn.clicked.connect(self.cancel_download_button_action) self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory).")) else: - # State: Idle (No download, no restore pending) self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) self.download_btn.setEnabled(True) self.download_btn.clicked.connect(self.start_download) @@ -870,7 +856,7 @@ class DownloaderApp (QWidget ): self ._handle_actual_file_downloaded (payload [0 ]if payload else {}) elif signal_type =='file_successfully_downloaded': self ._handle_file_successfully_downloaded (payload [0 ]) - elif signal_type == 'worker_finished': # <-- ADD THIS ELIF BLOCK + elif signal_type == 'worker_finished': self.actual_gui_signals.worker_finished_signal.emit(payload[0] if payload else tuple()) else: self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }") @@ -1344,13 +1330,7 @@ class DownloaderApp (QWidget ): def _show_future_settings_dialog(self): """Shows the placeholder dialog for future settings.""" - # --- DEBUGGING CODE TO FIND THE UNEXPECTED CALL --- - import traceback - print("--- DEBUG: _show_future_settings_dialog() was called. See stack trace below. ---") - traceback.print_stack() - print("--------------------------------------------------------------------------------") - # Correctly create the dialog instance once with the parent set to self. dialog = FutureSettingsDialog(self) dialog.exec_() @@ -1364,7 +1344,6 @@ class DownloaderApp (QWidget ): Checks if the fetcher thread is done AND if all submitted tasks have been processed. If so, finalizes the download. """ - # Conditions for being completely finished: fetcher_is_done = not self.is_fetcher_thread_running all_workers_are_done = (self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process) @@ -1643,24 +1622,15 @@ class DownloaderApp (QWidget ): is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () if is_only_links_mode: - # Check if this is a new post title if post_title != self._current_link_post_title: - # Add a styled horizontal rule as a separator if self._current_link_post_title is not None: separator_html = f'{HTML_PREFIX}


' self.log_signal.emit(separator_html) - - # Display the new post title as a styled heading title_html = f'{HTML_PREFIX}

{html.escape(post_title)}

' self.log_signal.emit(title_html) self._current_link_post_title = post_title - - # Sanitize the link text for safe HTML display display_text = html.escape(link_text.strip() if link_text.strip() else link_url) - - # Build the HTML for the link item for a cleaner look link_html_parts = [ - # Use a div for indentation and a bullet point for list-like appearance f'
' f'• {display_text}' f' ({html.escape(platform)})' @@ -1668,7 +1638,6 @@ class DownloaderApp (QWidget ): if decryption_key: link_html_parts.append( - # Display key on a new line, indented, and in a different color f'
' f'Key: {html.escape(decryption_key)}' ) @@ -1677,8 +1646,6 @@ class DownloaderApp (QWidget ): final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}' self.log_signal.emit(final_link_html) - - # This part handles the secondary log panel and remains the same elif self .show_external_links : separator ="-"*45 formatted_link_info = f"{link_text} - {link_url} - {platform}" @@ -1818,22 +1785,13 @@ class DownloaderApp (QWidget ): def _handle_filter_mode_change(self, button, checked): if not button or not checked: return - - # Define this variable early to ensure it's always available. is_only_links = (button == self.radio_only_links) - - # Handle the automatic disabling of multithreading for link extraction if hasattr(self, 'use_multithreading_checkbox'): if is_only_links: - # Disable multithreading for "Only Links" to avoid the bug self.use_multithreading_checkbox.setChecked(False) self.use_multithreading_checkbox.setEnabled(False) else: - # Re-enable the multithreading option for other modes. - # Other logic will handle disabling it if needed (e.g., for Manga Date mode). self.use_multithreading_checkbox.setEnabled(True) - - # Reset the "More" button text if another button is selected if button != self.radio_more and checked: self.radio_more.setText("More") self.more_filter_scope = None @@ -2257,8 +2215,6 @@ class DownloaderApp (QWidget ): def _handle_more_options_toggled(self, button, checked): """Shows the MoreOptionsDialog when the 'More' radio button is selected.""" - - # This block handles when the user clicks ON the "More" button. if button == self.radio_more and checked: current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT current_format = self.text_export_format or 'pdf' @@ -2274,26 +2230,17 @@ class DownloaderApp (QWidget ): self.more_filter_scope = dialog.get_selected_scope() self.text_export_format = dialog.get_selected_format() self.single_pdf_setting = dialog.get_single_pdf_state() - - # Define the variable based on the dialog's result is_any_pdf_mode = (self.text_export_format == 'pdf') - - # Update the radio button text to reflect the choice scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description" format_display = f" ({self.text_export_format.upper()})" if self.single_pdf_setting: format_display = " (Single PDF)" self.radio_more.setText(f"{scope_text}{format_display}") - - # --- Logic to Disable/Enable Checkboxes --- - # Disable multithreading for ANY PDF export if hasattr(self, 'use_multithreading_checkbox'): self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode) if is_any_pdf_mode: self.use_multithreading_checkbox.setChecked(False) self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) - - # Also disable subfolders for the "Single PDF" case, as it doesn't apply if hasattr(self, 'use_subfolders_checkbox'): self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting) if self.single_pdf_setting: @@ -2304,16 +2251,12 @@ class DownloaderApp (QWidget ): if is_any_pdf_mode: self.log_signal.emit("ℹ️ Multithreading automatically disabled for PDF export.") else: - # User cancelled the dialog, so revert to the 'All' option. self.log_signal.emit("ℹ️ 'More' filter selection cancelled. Reverting to 'All'.") self.radio_all.setChecked(True) - - # This block handles when the user switches AWAY from "More" to another option. elif button != self.radio_more and checked: self.radio_more.setText("More") self.more_filter_scope = None self.single_pdf_setting = False - # Re-enable the checkboxes when switching to any non-PDF mode if hasattr(self, 'use_multithreading_checkbox'): self.use_multithreading_checkbox.setEnabled(True) self._update_multithreading_for_date_mode() @@ -2383,7 +2326,6 @@ class DownloaderApp (QWidget ): self .use_subfolder_per_post_checkbox .setChecked (False ) if hasattr(self, 'date_prefix_checkbox'): - # The Date Prefix checkbox should only be enabled if "Subfolder per Post" is both enabled and checked can_enable_date_prefix = self.use_subfolder_per_post_checkbox.isEnabled() and self.use_subfolder_per_post_checkbox.isChecked() self.date_prefix_checkbox.setEnabled(can_enable_date_prefix) if not can_enable_date_prefix: @@ -3435,7 +3377,6 @@ class DownloaderApp (QWidget ): def _load_ui_from_settings_dict(self, settings: dict): """Populates the UI with values from a settings dictionary.""" - # Text inputs self.link_input.setText(settings.get('api_url', '')) self.dir_input.setText(settings.get('output_dir', '')) self.character_input.setText(settings.get('character_filter_text', '')) @@ -3445,19 +3386,13 @@ class DownloaderApp (QWidget ): self.cookie_text_input.setText(settings.get('cookie_text', '')) if hasattr(self, 'manga_date_prefix_input'): self.manga_date_prefix_input.setText(settings.get('manga_date_prefix', '')) - - # Numeric inputs self.thread_count_input.setText(str(settings.get('num_threads', 4))) self.start_page_input.setText(str(settings.get('start_page', '')) if settings.get('start_page') is not None else '') self.end_page_input.setText(str(settings.get('end_page', '')) if settings.get('end_page') is not None else '') - - # Checkboxes for checkbox_name, key in self.get_checkbox_map().items(): checkbox = getattr(self, checkbox_name, None) if checkbox: checkbox.setChecked(settings.get(key, False)) - - # Radio buttons if settings.get('only_links'): self.radio_only_links.setChecked(True) else: filter_mode = settings.get('filter_mode', 'all') @@ -3469,17 +3404,12 @@ class DownloaderApp (QWidget ): self.keep_duplicates_mode = settings.get('keep_duplicates_mode', DUPLICATE_HANDLING_HASH) self.keep_duplicates_limit = settings.get('keep_duplicates_limit', 0) - # Visually update the checkbox based on the restored mode if hasattr(self, 'keep_duplicates_checkbox'): is_keep_mode = (self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL) self.keep_duplicates_checkbox.setChecked(is_keep_mode) - - # Restore "More" dialog settings self.more_filter_scope = settings.get('more_filter_scope') self.text_export_format = settings.get('text_export_format', 'pdf') self.single_pdf_setting = settings.get('single_pdf_setting', False) - - # Visually update the "More" button's text to reflect the restored settings if self.radio_more.isChecked() and self.more_filter_scope: from .dialogs.MoreOptionsDialog import MoreOptionsDialog scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description" @@ -3487,14 +3417,10 @@ class DownloaderApp (QWidget ): if self.single_pdf_setting: format_display = " (Single PDF)" self.radio_more.setText(f"{scope_text}{format_display}") - - # Toggle button states self.skip_words_scope = settings.get('skip_words_scope', SKIP_SCOPE_POSTS) self.char_filter_scope = settings.get('char_filter_scope', CHAR_SCOPE_TITLE) self.manga_filename_style = settings.get('manga_filename_style', STYLE_POST_TITLE) self.allow_multipart_download_setting = settings.get('allow_multipart_download', False) - - # Update button texts after setting states self._update_skip_scope_button_text() self._update_char_filter_scope_button_text() self._update_manga_filename_style_button_text() @@ -3541,17 +3467,15 @@ class DownloaderApp (QWidget ): in multi-threaded mode. """ global PostProcessorWorker, download_from_api, requests, json, traceback, urlparse - - # Unpack arguments from the dictionary passed by the thread api_url_input_for_fetcher = fetcher_args['api_url'] worker_args_template = fetcher_args['worker_args_template'] processed_post_ids_set = set(fetcher_args.get('processed_post_ids', [])) - start_offset = fetcher_args.get('start_offset', 0) + start_page = worker_args_template.get('start_page') + end_page = worker_args_template.get('end_page') target_post_id = worker_args_template.get('target_post_id_from_initial_url') # Get the target post ID logger_func = lambda msg: self.log_signal.emit(f"[Fetcher] {msg}") try: - # Prepare common variables for the fetcher thread service = worker_args_template.get('service') user_id = worker_args_template.get('user_id') cancellation_event = self.cancellation_event @@ -3582,8 +3506,6 @@ class DownloaderApp (QWidget ): if not isinstance(single_post_data, dict): raise ValueError(f"Expected a dictionary for post data, but got {type(single_post_data)}") - - # Set total posts to 1 and submit the single job to the worker pool self.total_posts_to_process = 1 self.overall_progress_signal.emit(1, 0) @@ -3600,8 +3522,12 @@ class DownloaderApp (QWidget ): logger_func(f"❌ Failed to fetch single post directly: {e}. Aborting.") return - offset = start_offset page_size = 50 + offset = 0 + current_page_num = 1 + if start_page and start_page > 1: + offset = (start_page - 1) * page_size + current_page_num = start_page while not cancellation_event.is_set(): while pause_event.is_set(): @@ -3609,6 +3535,10 @@ class DownloaderApp (QWidget ): if cancellation_event.is_set(): break if cancellation_event.is_set(): break + if end_page and current_page_num > end_page: + logger_func(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.") + break + api_url = f"https://{parsed_api_url.netloc}/api/v1/{service}/user/{user_id}?o={offset}" logger_func(f"Fetching post list: {api_url} (Page approx. {offset // page_size + 1})") @@ -3617,7 +3547,8 @@ class DownloaderApp (QWidget ): response.raise_for_status() posts_batch_from_api = response.json() except (requests.RequestException, json.JSONDecodeError) as e: - logger_func(f"❌ API Error fetching posts: {e}. Stopping fetch.") + logger_func(f"❌ API Error fetching posts: {e}. Aborting the entire download.") + self.cancellation_event.set() break if not posts_batch_from_api: @@ -3656,7 +3587,8 @@ class DownloaderApp (QWidget ): except (json.JSONDecodeError, KeyError, OSError) as e: logger_func(f"⚠️ Could not update session offset: {e}") - offset = next_offset + offset = offset + page_size + current_page_num += 1 except Exception as e: logger_func(f"❌ Critical error during post fetching: {e}\n{traceback.format_exc(limit=2)}") @@ -3686,8 +3618,6 @@ class DownloaderApp (QWidget ): if permanent: self.permanently_failed_files_for_dialog.extend(permanent) self._update_error_button_count() - - # Other result handling if history_data: self._add_to_history_candidates(history_data) self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) @@ -4023,7 +3953,7 @@ class DownloaderApp (QWidget ): self.is_finishing = True if not self .cancel_btn .isEnabled ()and not self .cancellation_event .is_set ():self .log_signal .emit ("ℹ️ No active download to cancel or already cancelling.");return self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...") - + self._cleanup_temp_files() self._clear_session_file() # Clear session file on explicit cancel if self .external_link_download_thread and self .external_link_download_thread .isRunning (): self .log_signal .emit (" Cancelling active External Link download thread...") @@ -4205,8 +4135,6 @@ class DownloaderApp (QWidget ): self.log_signal.emit(f"ℹ️ Duplicate handling mode set to: '{self.keep_duplicates_mode}' {limit_text}.") self.log_signal.emit(f"") self.log_signal.emit(f"") - - # Log warning only after the confirmation and only if the specific mode is selected if self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL: self._log_keep_everything_warning() else: @@ -4394,14 +4322,11 @@ class DownloaderApp (QWidget ): if os.path.exists(self.session_file_path): try: with self.session_lock: - # Read the current session data with open(self.session_file_path, 'r', encoding='utf-8') as f: session_data = json.load(f) if 'download_state' in session_data: session_data['download_state']['permanently_failed_files'] = self.permanently_failed_files_for_dialog - - # Save the updated session data back to the file self._save_session_file(session_data) self.log_signal.emit("ℹ️ Session file updated with retry results.") @@ -4457,23 +4382,17 @@ class DownloaderApp (QWidget ): self.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.") self.download_thread.deleteLater() self.download_thread = None - - # Try to cancel thread pool if self.thread_pool: self.log_signal.emit(" Shutting down thread pool for reset...") self.thread_pool.shutdown(wait=True, cancel_futures=True) self.thread_pool = None self.active_futures = [] - - # Try to cancel external link download thread if self.external_link_download_thread and self.external_link_download_thread.isRunning(): self.log_signal.emit(" Cancelling external link download thread for reset...") self.external_link_download_thread.cancel() self.external_link_download_thread.wait(3000) self.external_link_download_thread.deleteLater() self.external_link_download_thread = None - - # Try to cancel retry thread pool if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool: self.log_signal.emit(" Shutting down retry thread pool for reset...") self.retry_thread_pool.shutdown(wait=True) @@ -4494,9 +4413,6 @@ class DownloaderApp (QWidget ): self._load_saved_download_location() self.main_log_output.clear() self.external_log_output.clear() - - - # --- Reset UI and all state --- self.log_signal.emit("🔄 Resetting application state to defaults...") self._reset_ui_to_defaults() self._load_saved_download_location() @@ -4513,8 +4429,6 @@ class DownloaderApp (QWidget ): if self.log_verbosity_toggle_button: self.log_verbosity_toggle_button.setText(self.EYE_ICON) self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") - - # Clear all download-related state self.external_link_queue.clear() self.extracted_links_cache = [] self._is_processing_external_link_queue = False @@ -4564,11 +4478,9 @@ class DownloaderApp (QWidget ): self.interrupted_session_data = None self.is_restore_pending = False self.last_link_input_text_for_queue_sync = "" - # Replace your current reset_application_state with the above. def _reset_ui_to_defaults(self): """Resets all UI elements and relevant state to their default values.""" - # Clear all text fields self.link_input.clear() self.custom_folder_input.clear() self.character_input.clear() @@ -4582,8 +4494,6 @@ class DownloaderApp (QWidget ): self.thread_count_input.setText("4") if hasattr(self, 'manga_date_prefix_input'): self.manga_date_prefix_input.clear() - - # Set radio buttons and checkboxes to defaults self.radio_all.setChecked(True) self.skip_zip_checkbox.setChecked(True) self.download_thumbnails_checkbox.setChecked(False) @@ -4605,8 +4515,6 @@ class DownloaderApp (QWidget ): self.selected_cookie_filepath = None if hasattr(self, 'cookie_text_input'): self.cookie_text_input.clear() - - # Reset log and progress displays if self.main_log_output: self.main_log_output.clear() if self.external_log_output: @@ -4615,8 +4523,6 @@ class DownloaderApp (QWidget ): self.missed_character_log_output.clear() self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle")) self.file_progress_label.setText("") - - # Reset internal state self.missed_title_key_terms_count.clear() self.missed_title_key_terms_examples.clear() self.logged_summary_for_key_term.clear() @@ -4647,8 +4553,6 @@ class DownloaderApp (QWidget ): self._current_link_post_title = None if self.download_extracted_links_button: self.download_extracted_links_button.setEnabled(False) - - # Reset favorite/queue/session state self.favorite_download_queue.clear() self.is_processing_favorites_queue = False self.current_processing_favorite_item_info = None @@ -4656,14 +4560,11 @@ class DownloaderApp (QWidget ): self.is_restore_pending = False self.last_link_input_text_for_queue_sync = "" self._update_button_states_and_connections() - # Reset counters and progress self.total_posts_to_process = 0 self.processed_posts_count = 0 self.download_counter = 0 self.skip_counter = 0 self.all_kept_original_filenames = [] - - # Reset log view and UI state if self.log_view_stack: self.log_view_stack.setCurrentIndex(0) if self.progress_log_label: @@ -4671,27 +4572,19 @@ class DownloaderApp (QWidget ): if self.log_verbosity_toggle_button: self.log_verbosity_toggle_button.setText(self.EYE_ICON) self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") - - # Reset character list filter self.filter_character_list("") - - # Update UI for manga mode and multithreading self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) self.update_ui_for_manga_mode(False) self.update_custom_folder_visibility(self.link_input.text()) self.update_page_range_enabled_state() self._update_cookie_input_visibility(False) self._update_cookie_input_placeholders_and_tooltips() - - # Reset button states self.download_btn.setEnabled(True) self.cancel_btn.setEnabled(False) if self.reset_button: self.reset_button.setEnabled(True) self.reset_button.setText(self._tr("reset_button_text", "🔄 Reset")) self.reset_button.setToolTip(self._tr("reset_button_tooltip", "Reset all inputs and logs to default state (only when idle).")) - - # Reset favorite mode UI if hasattr(self, 'favorite_mode_checkbox'): self._handle_favorite_mode_toggle(False) if hasattr(self, 'scan_content_images_checkbox'): @@ -4887,8 +4780,6 @@ class DownloaderApp (QWidget ): self._tr("restore_pending_message_creator_selection", "Please 'Restore Download' or 'Discard Session' before selecting new creators.")) return - - # Correctly create the dialog instance dialog = EmptyPopupDialog(self.app_base_dir, self) if dialog.exec_() == QDialog.Accepted: if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: