This commit is contained in:
Yuvi9587
2025-07-19 03:28:32 -07:00
parent 33133eb275
commit fbdae61b80
15 changed files with 194 additions and 376 deletions

View File

@@ -3,8 +3,6 @@ import traceback
from urllib.parse import urlparse from urllib.parse import urlparse
import json # Ensure json is imported import json # Ensure json is imported
import requests import requests
# (Keep the rest of your imports)
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import ( from ..config.constants import (
STYLE_DATE_POST_TITLE 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.") raise RuntimeError("Fetch operation cancelled by user while paused.")
time.sleep(0.5) time.sleep(0.5)
logger(" Post fetching resumed.") 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" 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}' 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) logger(log_message)
try: 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 = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status() response.raise_for_status()
return response.json() 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}" 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}...") logger(f" Fetching full content for post ID {post_id}...")
try: 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: with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
response.raise_for_status() response.raise_for_status()
response_body = b"" response_body = b""
@@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
response_body += chunk response_body += chunk
full_post_data = json.loads(response_body) 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: if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0] return full_post_data[0]
return full_post_data return full_post_data
@@ -134,14 +126,10 @@ def download_from_api(
'User-Agent': 'Mozilla/5.0', 'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json' 'Accept': 'application/json'
} }
# --- ADD THIS BLOCK ---
# Ensure processed_post_ids is a set for fast lookups
if processed_post_ids is None: if processed_post_ids is None:
processed_post_ids = set() processed_post_ids = set()
else: else:
processed_post_ids = set(processed_post_ids) processed_post_ids = set(processed_post_ids)
# --- END OF ADDITION ---
service, user_id, target_post_id = extract_post_info(api_url_input) 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: 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) 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: if target_post_id:
# --- ADD THIS CHECK FOR RESTORE ---
if target_post_id in processed_post_ids: if target_post_id in processed_post_ids:
logger(f" Skipping already processed target post ID: {target_post_id}") logger(f" Skipping already processed target post ID: {target_post_id}")
return return
# --- END OF ADDITION ---
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}" direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}") logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try: try:
@@ -248,14 +234,12 @@ def download_from_api(
break break
if cancellation_event and cancellation_event.is_set(): return if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode: if all_posts_for_manga_mode:
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
if processed_post_ids: if processed_post_ids:
original_count = len(all_posts_for_manga_mode) 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] 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) skipped_count = original_count - len(all_posts_for_manga_mode)
if skipped_count > 0: if skipped_count > 0:
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.") 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)...") logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple(post): 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}") logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback.print_exc() traceback.print_exc()
break break
# --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
if processed_post_ids: if processed_post_ids:
original_count = len(posts_batch) original_count = len(posts_batch)
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids] posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(posts_batch) skipped_count = original_count - len(posts_batch)
if skipped_count > 0: if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.") logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
# --- END OF ADDITION ---
if not posts_batch: if not posts_batch:
if target_post_id and not processed_target_post_flag: if target_post_id and not processed_target_post_flag:

View File

@@ -1,13 +1,9 @@
# --- Standard Library Imports ---
import threading import threading
import time import time
import os import os
import json import json
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future 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 .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread from .workers import PostProcessorWorker, DownloadThread
from ..config.constants import ( from ..config.constants import (
@@ -36,8 +32,6 @@ class DownloadManager:
self.progress_queue = progress_queue self.progress_queue = progress_queue
self.thread_pool = None self.thread_pool = None
self.active_futures = [] self.active_futures = []
# --- Session State ---
self.cancellation_event = threading.Event() self.cancellation_event = threading.Event()
self.pause_event = threading.Event() self.pause_event = threading.Event()
self.is_running = False self.is_running = False
@@ -64,8 +58,6 @@ class DownloadManager:
if self.is_running: if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.") self._log("❌ Cannot start a new session: A session is already in progress.")
return return
# --- Reset state for the new session ---
self.is_running = True self.is_running = True
self.cancellation_event.clear() self.cancellation_event.clear()
self.pause_event.clear() self.pause_event.clear()
@@ -75,8 +67,6 @@ class DownloadManager:
self.total_downloads = 0 self.total_downloads = 0
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] 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')) is_single_post = bool(config.get('target_post_id_from_initial_url'))
use_multithreading = config.get('use_multithreading', True) 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] 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 should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
# Start a separate thread to manage fetching and queuing to the thread pool
fetcher_thread = threading.Thread( fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool, target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data), args=(config, restore_data),
@@ -92,16 +81,11 @@ class DownloadManager:
) )
fetcher_thread.start() fetcher_thread.start()
else: 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) self._start_single_threaded_session(config)
def _start_single_threaded_session(self, config): def _start_single_threaded_session(self, config):
"""Handles downloads that are best processed by a single worker thread.""" """Handles downloads that are best processed by a single worker thread."""
self._log(" Initializing single-threaded download process...") 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( self.worker_thread = threading.Thread(
target=self._run_single_worker, target=self._run_single_worker,
args=(config,), args=(config,),
@@ -112,7 +96,6 @@ class DownloadManager:
def _run_single_worker(self, config): def _run_single_worker(self, config):
"""Target function for the single-worker thread.""" """Target function for the single-worker thread."""
try: try:
# Pass the queue directly to the worker for it to send updates
worker = DownloadThread(config, self.progress_queue) worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread worker.run() # This is the main blocking call for this thread
except Exception as e: except Exception as e:
@@ -129,9 +112,6 @@ class DownloadManager:
try: try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS) num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') 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: if restore_data:
all_posts = restore_data['all_posts_data'] all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids']) processed_ids = set(restore_data['processed_post_ids'])
@@ -149,12 +129,9 @@ class DownloadManager:
if not posts_to_process: if not posts_to_process:
self._log("✅ No new posts to process.") self._log("✅ No new posts to process.")
return return
# Submit tasks to the pool
for post_data in posts_to_process: for post_data in posts_to_process:
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
break break
# Each PostProcessorWorker gets the queue to send its own updates
worker = PostProcessorWorker(post_data, config, self.progress_queue) worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process) future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result) 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(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
self._log(traceback.format_exc()) self._log(traceback.format_exc())
finally: finally:
# Wait for all submitted tasks to complete before shutting down
if self.thread_pool: if self.thread_pool:
self.thread_pool.shutdown(wait=True) self.thread_pool.shutdown(wait=True)
self.is_running = False self.is_running = False
self._log("🏁 All processing tasks have completed.") self._log("🏁 All processing tasks have completed.")
# Emit final signal
self.progress_queue.put({ self.progress_queue.put({
'type': 'finished', 'type': 'finished',
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
}) })
def _get_all_posts(self, config): def _get_all_posts(self, config):
"""Helper to fetch all posts using the API client.""" """Helper to fetch all posts using the API client."""
all_posts = [] all_posts = []
# This generator yields batches of posts
post_generator = download_from_api( post_generator = download_from_api(
api_url_input=config['api_url'], api_url_input=config['api_url'],
logger=self._log, 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, 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: for batch in post_generator:
all_posts.extend(batch) all_posts.extend(batch)
@@ -203,14 +185,11 @@ class DownloadManager:
self.total_skips += 1 self.total_skips += 1
else: else:
result = future.result() result = future.result()
# Unpack result tuple from the worker
(dl_count, skip_count, kept_originals, (dl_count, skip_count, kept_originals,
retryable, permanent, history) = result retryable, permanent, history) = result
self.total_downloads += dl_count self.total_downloads += dl_count
self.total_skips += skip_count self.total_skips += skip_count
self.all_kept_original_filenames.extend(kept_originals) self.all_kept_original_filenames.extend(kept_originals)
# Queue up results for UI to handle
if retryable: if retryable:
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)}) self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
if permanent: if permanent:
@@ -221,8 +200,6 @@ class DownloadManager:
except Exception as e: except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}") self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped 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)}) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def cancel_session(self): def cancel_session(self):
@@ -231,11 +208,7 @@ class DownloadManager:
return return
self._log("⚠️ Cancellation requested by user...") self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set() 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: 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.thread_pool.shutdown(wait=False, cancel_futures=True)
self.is_running = False self.is_running = False

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import os import os
import queue import queue
import re import re
@@ -15,15 +14,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError,
from io import BytesIO from io import BytesIO
from urllib .parse import urlparse from urllib .parse import urlparse
import requests import requests
# --- Third-Party Library Imports ---
try: try:
from PIL import Image from PIL import Image
except ImportError: except ImportError:
Image = None Image = None
#
try: try:
from fpdf import FPDF from fpdf import FPDF
# Add a simple class to handle the header/footer for stories
class PDF(FPDF): class PDF(FPDF):
def header(self): def header(self):
pass # No header pass # No header
@@ -39,16 +35,12 @@ try:
from docx import Document from docx import Document
except ImportError: except ImportError:
Document = None Document = None
# --- PyQt5 Imports ---
from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess 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 .api_client import download_from_api, fetch_post_comments
from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE
from ..services.drive_downloader import ( from ..services.drive_downloader import (
download_mega_file, download_gdrive_file, download_dropbox_file download_mega_file, download_gdrive_file, download_dropbox_file
) )
# Corrected Imports:
from ..utils.file_utils import ( from ..utils.file_utils import (
is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES, is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES,
clean_filename, clean_folder_name clean_filename, clean_folder_name
@@ -567,10 +559,8 @@ class PostProcessorWorker:
with self.downloaded_hash_counts_lock: with self.downloaded_hash_counts_lock:
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0) current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
# Default to not skipping
decision_to_skip = False decision_to_skip = False
# Apply logic based on mode
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH: if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
if current_count >= 1: if current_count >= 1:
decision_to_skip = True decision_to_skip = True
@@ -581,12 +571,10 @@ class PostProcessorWorker:
decision_to_skip = True 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.") 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: if not decision_to_skip:
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1 self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
should_skip = decision_to_skip should_skip = decision_to_skip
# --- End of Final Corrected Logic ---
if should_skip: if should_skip:
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
@@ -678,9 +666,14 @@ class PostProcessorWorker:
else: else:
self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.") self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.")
details_for_failure = { details_for_failure = {
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'file_info': file_info,
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'target_folder_path': target_folder_path,
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post '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: 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 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 return result_tuple
raw_text_content = "" raw_text_content = ""
comments_data = []
final_post_data = post_data final_post_data = post_data
if self.text_only_scope == 'content' and 'content' not in final_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...") self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
parsed_url = urlparse(self.api_url_input) 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) full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
if full_data: if full_data:
final_post_data = full_data final_post_data = full_data
if self.text_only_scope == 'content': if self.text_only_scope == 'content':
raw_text_content = final_post_data.get('content', '') raw_text_content = final_post_data.get('content', '')
elif self.text_only_scope == 'comments': elif self.text_only_scope == 'comments':
@@ -1060,46 +1056,46 @@ class PostProcessorWorker:
if comments_data: if comments_data:
comment_texts = [] comment_texts = []
for comment in comments_data: for comment in comments_data:
user = comment.get('user', {}).get('name', 'Unknown User') user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('updated', 'No Date') timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', '')) body = strip_html_tags(comment.get('content', ''))
comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n") comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n")
raw_text_content = "\n".join(comment_texts) raw_text_content = "\n".join(comment_texts)
else:
raw_text_content = ""
except Exception as e: except Exception as e:
self.logger(f" ❌ Error fetching comments for text-only mode: {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)</p>|<br\s*/?>', '\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.") self.logger(" -> Skip Saving Text: No content/comments found or fetched.")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple return result_tuple
paragraph_pattern = re.compile(r'<p.*?>(.*?)</p>', re.IGNORECASE | re.DOTALL)
html_paragraphs = paragraph_pattern.findall(raw_text_content)
cleaned_text = ""
if not html_paragraphs:
self.logger(" ⚠️ No <p> tags found. Falling back to basic HTML cleaning for the whole block.")
text_with_br = re.sub(r'<br\s*/?>', '\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'<br\s*/?>', '\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 self.single_pdf_mode:
if not cleaned_text:
result_tuple = (0, 0, [], [], [], None, None)
return result_tuple
content_data = { content_data = {
'title': post_title, 'title': post_title,
'content': cleaned_text,
'published': self.post.get('published') or self.post.get('added') '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") temp_dir = os.path.join(self.app_base_dir, "appdata")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json" temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json"
@@ -1107,13 +1103,11 @@ class PostProcessorWorker:
try: try:
with open(temp_filepath, 'w', encoding='utf-8') as f: with open(temp_filepath, 'w', encoding='utf-8') as f:
json.dump(content_data, f, indent=2) json.dump(content_data, f, indent=2)
self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.") self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
result_tuple = (0, 0, [], [], [], None, temp_filepath) return (0, 0, [], [], [], None, temp_filepath)
return result_tuple
except Exception as e: except Exception as e:
self.logger(f" ❌ Failed to write temporary file for single PDF: {e}") self.logger(f" ❌ Failed to write temporary file for single PDF: {e}")
result_tuple = (0, 0, [], [], [], None, None) return (0, 0, [], [], [], None, None)
return result_tuple
else: else:
file_extension = self.text_export_format file_extension = self.text_export_format
txt_filename = clean_filename(post_title) + f".{file_extension}" txt_filename = clean_filename(post_title) + f".{file_extension}"
@@ -1125,27 +1119,63 @@ class PostProcessorWorker:
while os.path.exists(final_save_path): while os.path.exists(final_save_path):
final_save_path = f"{base}_{counter}{ext}" final_save_path = f"{base}_{counter}{ext}"
counter += 1 counter += 1
if file_extension == 'pdf': if file_extension == 'pdf':
if FPDF: 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() pdf = PDF()
font_path = "" font_path = ""
bold_font_path = ""
if self.project_root_dir: if self.project_root_dir:
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') 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: try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") 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.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: except Exception as font_error:
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") 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.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) pdf.output(final_save_path)
else: else:
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
final_save_path = os.path.splitext(final_save_path)[0] + ".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) with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
elif file_extension == 'docx': elif file_extension == 'docx':
if Document: if Document:
self.logger(f" Converting to DOCX...") 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.") 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" 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) 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: with open(final_save_path, 'w', encoding='utf-8') as f:
f.write(cleaned_text) 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)}'") 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) result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
return result_tuple return result_tuple
except Exception as e: except Exception as e:
self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}") self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
@@ -1263,7 +1296,6 @@ class PostProcessorWorker:
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH: if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
unique_files_by_url = {} unique_files_by_url = {}
for file_info in all_files_from_post_api: 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') file_url = file_info.get('url')
if file_url and file_url not in unique_files_by_url: if file_url and file_url not in unique_files_by_url:
unique_files_by_url[file_url] = file_info unique_files_by_url[file_url] = file_info
@@ -1734,7 +1766,6 @@ class DownloadThread(QThread):
worker_signals_obj = PostProcessorSignals() worker_signals_obj = PostProcessorSignals()
try: try:
# Connect signals
worker_signals_obj.progress_signal.connect(self.progress_signal) 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_download_status_signal.connect(self.file_download_status_signal)
worker_signals_obj.file_progress_signal.connect(self.file_progress_signal) worker_signals_obj.file_progress_signal.connect(self.file_progress_signal)
@@ -1771,8 +1802,6 @@ class DownloadThread(QThread):
was_process_cancelled = True was_process_cancelled = True
break break
# --- START OF FIX: Explicitly build the arguments dictionary ---
# This robustly maps all thread attributes to the correct worker parameters.
worker_args = { worker_args = {
'post_data': individual_post_data, 'post_data': individual_post_data,
'emitter': worker_signals_obj, 'emitter': worker_signals_obj,
@@ -1833,7 +1862,6 @@ class DownloadThread(QThread):
'single_pdf_mode': self.single_pdf_mode, 'single_pdf_mode': self.single_pdf_mode,
'project_root_dir': self.project_root_dir, 'project_root_dir': self.project_root_dir,
} }
# --- END OF FIX ---
post_processing_worker = PostProcessorWorker(**worker_args) post_processing_worker = PostProcessorWorker(**worker_args)
@@ -1860,6 +1888,7 @@ class DownloadThread(QThread):
if not was_process_cancelled and not self.isInterruptionRequested(): if not was_process_cancelled and not self.isInterruptionRequested():
self.logger("✅ All posts processed or end of content reached by DownloadThread.") self.logger("✅ All posts processed or end of content reached by DownloadThread.")
except Exception as main_thread_err: except Exception as main_thread_err:
self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}") self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}")
traceback.print_exc() traceback.print_exc()

View File

@@ -1,8 +1,5 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
_app_icon_cache = None _app_icon_cache = None

View File

@@ -1,18 +1,10 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout QPushButton, QVBoxLayout
) )
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..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_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
self.parent_app = parent_app self.parent_app = parent_app
self.setModal(True) self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list 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 self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0 scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350 base_min_w, base_min_h = 480, 350
scaled_min_w = int(base_min_w * scale_factor) scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
self.names_list_widget = QListWidget() self.names_list_widget = QListWidget()
self._populate_list() self._populate_list()
main_layout.addWidget(self.names_list_widget) main_layout.addWidget(self.names_list_widget)
# --- Selection Buttons ---
selection_buttons_layout = QHBoxLayout() selection_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items) 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.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch() selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout) main_layout.addLayout(selection_buttons_layout)
# --- Action Buttons ---
buttons_layout = QHBoxLayout() buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton() self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected) 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. sensible default if no items are selected but the "Add" button is clicked.
""" """
super().exec_() 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: if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice return self.user_choice

View File

@@ -1,14 +1,9 @@
# --- Standard Library Imports ---
from collections import defaultdict from collections import defaultdict
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme 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 A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox. from external cloud services like Mega, Google Drive, and Dropbox.
""" """
# Signal emitted with a list of selected link information dictionaries
download_requested = pyqtSignal(list) download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None): def __init__(self, links_data, parent_app, parent=None):
@@ -34,23 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.links_data = links_data self.links_data = links_data
self.parent_app = parent_app self.parent_app = parent_app
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if not app_icon.isNull(): if not app_icon.isNull():
self.setWindowIcon(app_icon) 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) 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 base_width, base_height = 600, 450
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) 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)) 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._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -68,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection) self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list() self._populate_list()
layout.addWidget(self.links_list_widget) layout.addWidget(self.links_list_widget)
# --- Control Buttons ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked)) 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()) sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles: 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 = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags) header_item.setFlags(Qt.NoItemFlags)
font = header_item.font() font = header_item.font()
@@ -108,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
font.setPointSize(font.pointSize() + 1) font.setPointSize(font.pointSize() + 1)
header_item.setFont(font) header_item.setFont(font)
self.links_list_widget.addItem(header_item) 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]: for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper() platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})" 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" is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
if is_dark_theme: if is_dark_theme:
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1) scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale)) self.setStyleSheet(get_dark_theme(scale))
else: else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("") self.setStyleSheet("")
# Set header text color based on theme
header_color = Qt.cyan if is_dark_theme else Qt.blue header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()): for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i) item = self.links_list_widget.item(i)
# Headers are not checkable (they have no checkable flag)
if not item.flags() & Qt.ItemIsUserCheckable: if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color) item.setForeground(header_color)

View File

@@ -1,16 +1,12 @@
# --- Standard Library Imports ---
import os import os
import time import time
import json import json
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea, QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox, QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox QFileDialog, QMessageBox
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
@@ -25,17 +21,14 @@ class DownloadHistoryDialog (QDialog ):
self .first_processed_entries =first_processed_entries self .first_processed_entries =first_processed_entries
self .setModal (True ) self .setModal (True )
self._apply_theme() 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) creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
if creator_name_cache: if creator_name_cache:
# Patch left pane (files)
for entry in self.last_3_downloaded_entries: for entry in self.last_3_downloaded_entries:
if not entry.get('creator_display_name'): if not entry.get('creator_display_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', '')) user_id = str(entry.get('user_id', ''))
key = (service, user_id) key = (service, user_id)
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series')) 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: for entry in self.first_processed_entries:
if not entry.get('creator_name'): if not entry.get('creator_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()

View File

@@ -42,11 +42,15 @@ class ErrorFilesDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) 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) 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 base_width, base_height = 550, 400
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) 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)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import html import html
import os import os
import sys import sys
@@ -8,8 +7,6 @@ import traceback
import json import json
import re import re
from collections import defaultdict from collections import defaultdict
# --- Third-Party Library Imports ---
import requests import requests
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -17,12 +14,9 @@ from PyQt5.QtWidgets import (
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
QWidget, QCheckBox QWidget, QCheckBox
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request from ...utils.network_utils import prepare_cookies_for_request
# Corrected Import: Import CookieHelpDialog directly from its own module
from .CookieHelpDialog import CookieHelpDialog from .CookieHelpDialog import CookieHelpDialog
from ...core.api_client import download_from_api from ...core.api_client import download_from_api
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme

View File

@@ -1,16 +1,11 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import QUrl, QSize, Qt from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget QStackedWidget, QScrollArea, QFrame, QWidget
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme

View File

@@ -1,13 +1,8 @@
# KeepDuplicatesDialog.py
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton, QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
) )
from PyQt5.QtGui import QIntValidator from PyQt5.QtGui import QIntValidator
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL 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'): if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self) self.parent_app._apply_theme_to_widget(self)
# Set the initial state based on current settings
if current_mode == DUPLICATE_HANDLING_KEEP_ALL: if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True) self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "") self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
@@ -44,13 +37,9 @@ class KeepDuplicatesDialog(QDialog):
options_group = QGroupBox() options_group = QGroupBox()
options_layout = QVBoxLayout(options_group) options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self) self.button_group = QButtonGroup(self)
# --- Skip by Hash Option ---
self.radio_skip_by_hash = QRadioButton() self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash) self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash) options_layout.addWidget(self.radio_skip_by_hash)
# --- Keep Everything Option with Limit Input ---
keep_everything_layout = QHBoxLayout() keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton() self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything) self.button_group.addButton(self.radio_keep_everything)
@@ -66,8 +55,6 @@ class KeepDuplicatesDialog(QDialog):
options_layout.addLayout(keep_everything_layout) options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group) main_layout.addWidget(options_group)
# --- OK and Cancel buttons ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.ok_button = QPushButton() self.ok_button = QPushButton()
self.cancel_button = QPushButton() self.cancel_button = QPushButton()
@@ -75,8 +62,6 @@ class KeepDuplicatesDialog(QDialog):
button_layout.addWidget(self.ok_button) button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout) main_layout.addLayout(button_layout)
# --- Connections ---
self.ok_button.clicked.connect(self.accept) self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled) self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)

View File

@@ -37,12 +37,16 @@ class KnownNamesFilterDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically # --- START OF FIX ---
screen_geometry = QApplication.primaryScreen().availableGeometry() # 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) 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 base_width, base_height = 460, 450
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor)) 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)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()

View File

@@ -1,34 +1,33 @@
# SinglePDF.py
import os import os
import re
try: try:
from fpdf import FPDF from fpdf import FPDF
FPDF_AVAILABLE = True FPDF_AVAILABLE = True
except ImportError: except ImportError:
FPDF_AVAILABLE = False FPDF_AVAILABLE = False
def strip_html_tags(text):
if not text:
return ""
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
class PDF(FPDF): class PDF(FPDF):
"""Custom PDF class to handle headers and footers.""" """Custom PDF class to handle headers and footers."""
def header(self): def header(self):
# No header pass
pass
def footer(self): def footer(self):
# Position at 1.5 cm from bottom
self.set_y(-15) self.set_y(-15)
self.set_font('DejaVu', '', 8) if self.font_family:
# Page number 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') 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): 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. Creates a single, continuous PDF, correctly formatting both descriptions and comments.
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.
""" """
if not FPDF_AVAILABLE: if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2") 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 return False
pdf = PDF() pdf = PDF()
default_font_family = 'DejaVu'
bold_font_path = ""
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try: try:
if not os.path.exists(font_path): if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
raise RuntimeError("Font file not found.") 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.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')
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 logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
pdf.set_font('DejaVu', '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) 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: try:
pdf.output(output_filename) pdf.output(output_filename)

View File

@@ -1,15 +1,10 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
@@ -58,8 +53,6 @@ class TourDialog(QDialog):
""" """
tour_finished_normally = pyqtSignal() tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal() tour_skipped = pyqtSignal()
# Constants for QSettings
CONFIG_APP_NAME_TOUR = "ApplicationTour" CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19" TOUR_SHOWN_KEY = "neverShowTourAgainV19"
@@ -98,8 +91,6 @@ class TourDialog(QDialog):
self.stacked_widget = QStackedWidget() self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1) main_layout.addWidget(self.stacked_widget, 1)
# Load content for each step
steps_content = [ steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"), ("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"), ("tour_dialog_step2_title", "tour_dialog_step2_content"),
@@ -120,8 +111,6 @@ class TourDialog(QDialog):
self.stacked_widget.addWidget(step_widget) self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!")) self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
# --- Bottom Controls ---
bottom_controls_layout = QVBoxLayout() bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15) bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12) bottom_controls_layout.setSpacing(12)

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import sys import sys
import os import os
import time import time
@@ -16,8 +15,6 @@ from collections import deque, defaultdict
import threading import threading
from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError
from urllib .parse import urlparse from urllib .parse import urlparse
# --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon, QIntValidator, QDesktopServices from PyQt5.QtGui import QIcon, QIntValidator, QDesktopServices
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
@@ -27,8 +24,6 @@ from PyQt5.QtWidgets import (
QMainWindow, QAction, QGridLayout, QMainWindow, QAction, QGridLayout,
) )
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker, QCoreApplication 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 ..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 DownloadThread as BackendDownloadThread
from ..core.workers import PostProcessorWorker from ..core.workers import PostProcessorWorker
@@ -137,8 +132,6 @@ class DownloaderApp (QWidget ):
self.creator_name_cache = {} self.creator_name_cache = {}
self.log_signal.emit(f" App base directory: {self.app_base_dir}") 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}") 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.last_downloaded_files_details = deque(maxlen=3)
self.download_history_candidates = deque(maxlen=8) self.download_history_candidates = deque(maxlen=8)
self.final_download_history_entries = [] self.final_download_history_entries = []
@@ -225,7 +218,7 @@ class DownloaderApp (QWidget ):
self.text_export_format = 'pdf' self.text_export_format = 'pdf'
self.single_pdf_setting = False self.single_pdf_setting = False
self.keep_duplicates_mode = DUPLICATE_HANDLING_HASH 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 = defaultdict(int)
self.downloaded_hash_counts_lock = threading.Lock() self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = [] self.session_temp_files = []
@@ -288,8 +281,6 @@ class DownloaderApp (QWidget ):
self.setStyleSheet(get_dark_theme(scale)) self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("") self.setStyleSheet("")
# Prompt for restart
msg_box = QMessageBox(self) msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information) msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("theme_change_title", "Theme Changed")) 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._load_ui_from_settings_dict(settings)
self.is_restore_pending = True 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): def _clear_session_and_reset_ui(self):
"""Clears the session file and resets the UI to its default state.""" """Clears the session file and resets the UI to its default state."""
self._clear_session_file() self._clear_session_file()
self.interrupted_session_data = None self.interrupted_session_data = None
self.is_restore_pending = False 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() self.reset_application_state()
def _clear_session_file(self): def _clear_session_file(self):
@@ -489,7 +480,6 @@ class DownloaderApp (QWidget ):
Updates the text and click connections of the main action buttons Updates the text and click connections of the main action buttons
based on the current application state (downloading, paused, restore pending, idle). 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() try: self.download_btn.clicked.disconnect()
except TypeError: pass except TypeError: pass
try: self.pause_btn.clicked.disconnect() try: self.pause_btn.clicked.disconnect()
@@ -500,7 +490,6 @@ class DownloaderApp (QWidget ):
is_download_active = self._is_download_active() is_download_active = self._is_download_active()
if self.is_restore_pending: if self.is_restore_pending:
# State: Restore Pending
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True) self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download) 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.clicked.connect(self.restore_download)
self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted 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.setText(self._tr("discard_session_button_text", "🗑️ Discard Session"))
self.cancel_btn.setEnabled(True) self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_session_and_reset_ui) 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.")) self.cancel_btn.setToolTip(self._tr("discard_session_tooltip", "Click to discard the interrupted session and reset the UI."))
elif is_download_active: elif is_download_active:
# State: Downloading / Paused
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) 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 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.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).")) 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: else:
# State: Idle (No download, no restore pending)
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True) self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download) 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 {}) self ._handle_actual_file_downloaded (payload [0 ]if payload else {})
elif signal_type =='file_successfully_downloaded': elif signal_type =='file_successfully_downloaded':
self ._handle_file_successfully_downloaded (payload [0 ]) 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()) self.actual_gui_signals.worker_finished_signal.emit(payload[0] if payload else tuple())
else: else:
self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }") 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): def _show_future_settings_dialog(self):
"""Shows the placeholder dialog for future settings.""" """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 = FutureSettingsDialog(self)
dialog.exec_() dialog.exec_()
@@ -1364,7 +1344,6 @@ class DownloaderApp (QWidget ):
Checks if the fetcher thread is done AND if all submitted tasks have been processed. Checks if the fetcher thread is done AND if all submitted tasks have been processed.
If so, finalizes the download. If so, finalizes the download.
""" """
# Conditions for being completely finished:
fetcher_is_done = not self.is_fetcher_thread_running 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) 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 () is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode: if is_only_links_mode:
# Check if this is a new post title
if post_title != self._current_link_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: if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">' separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html) self.log_signal.emit(separator_html)
# Display the new post title as a styled heading
title_html = f'{HTML_PREFIX}<h3 style="color: #87CEEB; margin-bottom: 5px; margin-top: 8px;">{html.escape(post_title)}</h3>' title_html = f'{HTML_PREFIX}<h3 style="color: #87CEEB; margin-bottom: 5px; margin-top: 8px;">{html.escape(post_title)}</h3>'
self.log_signal.emit(title_html) self.log_signal.emit(title_html)
self._current_link_post_title = post_title 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) 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 = [ link_html_parts = [
# Use a div for indentation and a bullet point for list-like appearance
f'<div style="margin-left: 20px; margin-bottom: 4px;">' f'<div style="margin-left: 20px; margin-bottom: 4px;">'
f'• <a href="{link_url}" style="color: #A9D0F5; text-decoration: none;">{display_text}</a>' f'• <a href="{link_url}" style="color: #A9D0F5; text-decoration: none;">{display_text}</a>'
f' <span style="color: #999;">({html.escape(platform)})</span>' f' <span style="color: #999;">({html.escape(platform)})</span>'
@@ -1668,7 +1638,6 @@ class DownloaderApp (QWidget ):
if decryption_key: if decryption_key:
link_html_parts.append( link_html_parts.append(
# Display key on a new line, indented, and in a different color
f'<br><span style="margin-left: 15px; color: #f0ad4e; font-size: 9pt;">' f'<br><span style="margin-left: 15px; color: #f0ad4e; font-size: 9pt;">'
f'Key: {html.escape(decryption_key)}</span>' f'Key: {html.escape(decryption_key)}</span>'
) )
@@ -1677,8 +1646,6 @@ class DownloaderApp (QWidget ):
final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}' final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}'
self.log_signal.emit(final_link_html) self.log_signal.emit(final_link_html)
# This part handles the secondary log panel and remains the same
elif self .show_external_links : elif self .show_external_links :
separator ="-"*45 separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}" formatted_link_info = f"{link_text} - {link_url} - {platform}"
@@ -1818,22 +1785,13 @@ class DownloaderApp (QWidget ):
def _handle_filter_mode_change(self, button, checked): def _handle_filter_mode_change(self, button, checked):
if not button or not checked: if not button or not checked:
return return
# Define this variable early to ensure it's always available.
is_only_links = (button == self.radio_only_links) is_only_links = (button == self.radio_only_links)
# Handle the automatic disabling of multithreading for link extraction
if hasattr(self, 'use_multithreading_checkbox'): if hasattr(self, 'use_multithreading_checkbox'):
if is_only_links: if is_only_links:
# Disable multithreading for "Only Links" to avoid the bug
self.use_multithreading_checkbox.setChecked(False) self.use_multithreading_checkbox.setChecked(False)
self.use_multithreading_checkbox.setEnabled(False) self.use_multithreading_checkbox.setEnabled(False)
else: 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) self.use_multithreading_checkbox.setEnabled(True)
# Reset the "More" button text if another button is selected
if button != self.radio_more and checked: if button != self.radio_more and checked:
self.radio_more.setText("More") self.radio_more.setText("More")
self.more_filter_scope = None self.more_filter_scope = None
@@ -2257,8 +2215,6 @@ class DownloaderApp (QWidget ):
def _handle_more_options_toggled(self, button, checked): def _handle_more_options_toggled(self, button, checked):
"""Shows the MoreOptionsDialog when the 'More' radio button is selected.""" """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: if button == self.radio_more and checked:
current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT
current_format = self.text_export_format or 'pdf' current_format = self.text_export_format or 'pdf'
@@ -2274,26 +2230,17 @@ class DownloaderApp (QWidget ):
self.more_filter_scope = dialog.get_selected_scope() self.more_filter_scope = dialog.get_selected_scope()
self.text_export_format = dialog.get_selected_format() self.text_export_format = dialog.get_selected_format()
self.single_pdf_setting = dialog.get_single_pdf_state() 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') 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" scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
format_display = f" ({self.text_export_format.upper()})" format_display = f" ({self.text_export_format.upper()})"
if self.single_pdf_setting: if self.single_pdf_setting:
format_display = " (Single PDF)" format_display = " (Single PDF)"
self.radio_more.setText(f"{scope_text}{format_display}") 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'): if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode) self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode)
if is_any_pdf_mode: if is_any_pdf_mode:
self.use_multithreading_checkbox.setChecked(False) self.use_multithreading_checkbox.setChecked(False)
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) 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'): if hasattr(self, 'use_subfolders_checkbox'):
self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting) self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting)
if self.single_pdf_setting: if self.single_pdf_setting:
@@ -2304,16 +2251,12 @@ class DownloaderApp (QWidget ):
if is_any_pdf_mode: if is_any_pdf_mode:
self.log_signal.emit(" Multithreading automatically disabled for PDF export.") self.log_signal.emit(" Multithreading automatically disabled for PDF export.")
else: else:
# User cancelled the dialog, so revert to the 'All' option.
self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.") self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.")
self.radio_all.setChecked(True) 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: elif button != self.radio_more and checked:
self.radio_more.setText("More") self.radio_more.setText("More")
self.more_filter_scope = None self.more_filter_scope = None
self.single_pdf_setting = False self.single_pdf_setting = False
# Re-enable the checkboxes when switching to any non-PDF mode
if hasattr(self, 'use_multithreading_checkbox'): if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True) self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode() self._update_multithreading_for_date_mode()
@@ -2383,7 +2326,6 @@ class DownloaderApp (QWidget ):
self .use_subfolder_per_post_checkbox .setChecked (False ) self .use_subfolder_per_post_checkbox .setChecked (False )
if hasattr(self, 'date_prefix_checkbox'): 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() 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) self.date_prefix_checkbox.setEnabled(can_enable_date_prefix)
if not 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): def _load_ui_from_settings_dict(self, settings: dict):
"""Populates the UI with values from a settings dictionary.""" """Populates the UI with values from a settings dictionary."""
# Text inputs
self.link_input.setText(settings.get('api_url', '')) self.link_input.setText(settings.get('api_url', ''))
self.dir_input.setText(settings.get('output_dir', '')) self.dir_input.setText(settings.get('output_dir', ''))
self.character_input.setText(settings.get('character_filter_text', '')) 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', '')) self.cookie_text_input.setText(settings.get('cookie_text', ''))
if hasattr(self, 'manga_date_prefix_input'): if hasattr(self, 'manga_date_prefix_input'):
self.manga_date_prefix_input.setText(settings.get('manga_date_prefix', '')) 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.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.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 '') 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(): for checkbox_name, key in self.get_checkbox_map().items():
checkbox = getattr(self, checkbox_name, None) checkbox = getattr(self, checkbox_name, None)
if checkbox: if checkbox:
checkbox.setChecked(settings.get(key, False)) checkbox.setChecked(settings.get(key, False))
# Radio buttons
if settings.get('only_links'): self.radio_only_links.setChecked(True) if settings.get('only_links'): self.radio_only_links.setChecked(True)
else: else:
filter_mode = settings.get('filter_mode', 'all') 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_mode = settings.get('keep_duplicates_mode', DUPLICATE_HANDLING_HASH)
self.keep_duplicates_limit = settings.get('keep_duplicates_limit', 0) 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'): if hasattr(self, 'keep_duplicates_checkbox'):
is_keep_mode = (self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL) is_keep_mode = (self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL)
self.keep_duplicates_checkbox.setChecked(is_keep_mode) self.keep_duplicates_checkbox.setChecked(is_keep_mode)
# Restore "More" dialog settings
self.more_filter_scope = settings.get('more_filter_scope') self.more_filter_scope = settings.get('more_filter_scope')
self.text_export_format = settings.get('text_export_format', 'pdf') self.text_export_format = settings.get('text_export_format', 'pdf')
self.single_pdf_setting = settings.get('single_pdf_setting', False) 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: if self.radio_more.isChecked() and self.more_filter_scope:
from .dialogs.MoreOptionsDialog import MoreOptionsDialog from .dialogs.MoreOptionsDialog import MoreOptionsDialog
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description" 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: if self.single_pdf_setting:
format_display = " (Single PDF)" format_display = " (Single PDF)"
self.radio_more.setText(f"{scope_text}{format_display}") 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.skip_words_scope = settings.get('skip_words_scope', SKIP_SCOPE_POSTS)
self.char_filter_scope = settings.get('char_filter_scope', CHAR_SCOPE_TITLE) 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.manga_filename_style = settings.get('manga_filename_style', STYLE_POST_TITLE)
self.allow_multipart_download_setting = settings.get('allow_multipart_download', False) 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_skip_scope_button_text()
self._update_char_filter_scope_button_text() self._update_char_filter_scope_button_text()
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
@@ -3541,17 +3467,15 @@ class DownloaderApp (QWidget ):
in multi-threaded mode. in multi-threaded mode.
""" """
global PostProcessorWorker, download_from_api, requests, json, traceback, urlparse 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'] api_url_input_for_fetcher = fetcher_args['api_url']
worker_args_template = fetcher_args['worker_args_template'] worker_args_template = fetcher_args['worker_args_template']
processed_post_ids_set = set(fetcher_args.get('processed_post_ids', [])) 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 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}") logger_func = lambda msg: self.log_signal.emit(f"[Fetcher] {msg}")
try: try:
# Prepare common variables for the fetcher thread
service = worker_args_template.get('service') service = worker_args_template.get('service')
user_id = worker_args_template.get('user_id') user_id = worker_args_template.get('user_id')
cancellation_event = self.cancellation_event cancellation_event = self.cancellation_event
@@ -3582,8 +3506,6 @@ class DownloaderApp (QWidget ):
if not isinstance(single_post_data, dict): if not isinstance(single_post_data, dict):
raise ValueError(f"Expected a dictionary for post data, but got {type(single_post_data)}") 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.total_posts_to_process = 1
self.overall_progress_signal.emit(1, 0) 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.") logger_func(f"❌ Failed to fetch single post directly: {e}. Aborting.")
return return
offset = start_offset
page_size = 50 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 not cancellation_event.is_set():
while pause_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 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}" 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})") 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() response.raise_for_status()
posts_batch_from_api = response.json() posts_batch_from_api = response.json()
except (requests.RequestException, json.JSONDecodeError) as e: 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 break
if not posts_batch_from_api: if not posts_batch_from_api:
@@ -3656,7 +3587,8 @@ class DownloaderApp (QWidget ):
except (json.JSONDecodeError, KeyError, OSError) as e: except (json.JSONDecodeError, KeyError, OSError) as e:
logger_func(f"⚠️ Could not update session offset: {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: except Exception as e:
logger_func(f"❌ Critical error during post fetching: {e}\n{traceback.format_exc(limit=2)}") logger_func(f"❌ Critical error during post fetching: {e}\n{traceback.format_exc(limit=2)}")
@@ -3686,8 +3618,6 @@ class DownloaderApp (QWidget ):
if permanent: if permanent:
self.permanently_failed_files_for_dialog.extend(permanent) self.permanently_failed_files_for_dialog.extend(permanent)
self._update_error_button_count() self._update_error_button_count()
# Other result handling
if history_data: self._add_to_history_candidates(history_data) if history_data: self._add_to_history_candidates(history_data)
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) 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 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 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 .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...")
self._cleanup_temp_files()
self._clear_session_file() # Clear session file on explicit cancel self._clear_session_file() # Clear session file on explicit cancel
if self .external_link_download_thread and self .external_link_download_thread .isRunning (): if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
self .log_signal .emit (" Cancelling active External Link download thread...") 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" Duplicate handling mode set to: '{self.keep_duplicates_mode}' {limit_text}.")
self.log_signal.emit(f"") self.log_signal.emit(f"")
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: if self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL:
self._log_keep_everything_warning() self._log_keep_everything_warning()
else: else:
@@ -4394,14 +4322,11 @@ class DownloaderApp (QWidget ):
if os.path.exists(self.session_file_path): if os.path.exists(self.session_file_path):
try: try:
with self.session_lock: with self.session_lock:
# Read the current session data
with open(self.session_file_path, 'r', encoding='utf-8') as f: with open(self.session_file_path, 'r', encoding='utf-8') as f:
session_data = json.load(f) session_data = json.load(f)
if 'download_state' in session_data: if 'download_state' in session_data:
session_data['download_state']['permanently_failed_files'] = self.permanently_failed_files_for_dialog 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._save_session_file(session_data)
self.log_signal.emit(" Session file updated with retry results.") 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.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.")
self.download_thread.deleteLater() self.download_thread.deleteLater()
self.download_thread = None self.download_thread = None
# Try to cancel thread pool
if self.thread_pool: if self.thread_pool:
self.log_signal.emit(" Shutting down thread pool for reset...") self.log_signal.emit(" Shutting down thread pool for reset...")
self.thread_pool.shutdown(wait=True, cancel_futures=True) self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.thread_pool = None self.thread_pool = None
self.active_futures = [] self.active_futures = []
# Try to cancel external link download thread
if self.external_link_download_thread and self.external_link_download_thread.isRunning(): 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.log_signal.emit(" Cancelling external link download thread for reset...")
self.external_link_download_thread.cancel() self.external_link_download_thread.cancel()
self.external_link_download_thread.wait(3000) self.external_link_download_thread.wait(3000)
self.external_link_download_thread.deleteLater() self.external_link_download_thread.deleteLater()
self.external_link_download_thread = None self.external_link_download_thread = None
# Try to cancel retry thread pool
if hasattr(self, 'retry_thread_pool') and self.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.log_signal.emit(" Shutting down retry thread pool for reset...")
self.retry_thread_pool.shutdown(wait=True) self.retry_thread_pool.shutdown(wait=True)
@@ -4494,9 +4413,6 @@ class DownloaderApp (QWidget ):
self._load_saved_download_location() self._load_saved_download_location()
self.main_log_output.clear() self.main_log_output.clear()
self.external_log_output.clear() self.external_log_output.clear()
# --- Reset UI and all state ---
self.log_signal.emit("🔄 Resetting application state to defaults...") self.log_signal.emit("🔄 Resetting application state to defaults...")
self._reset_ui_to_defaults() self._reset_ui_to_defaults()
self._load_saved_download_location() self._load_saved_download_location()
@@ -4513,8 +4429,6 @@ class DownloaderApp (QWidget ):
if self.log_verbosity_toggle_button: if self.log_verbosity_toggle_button:
self.log_verbosity_toggle_button.setText(self.EYE_ICON) 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.") 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.external_link_queue.clear()
self.extracted_links_cache = [] self.extracted_links_cache = []
self._is_processing_external_link_queue = False self._is_processing_external_link_queue = False
@@ -4564,11 +4478,9 @@ class DownloaderApp (QWidget ):
self.interrupted_session_data = None self.interrupted_session_data = None
self.is_restore_pending = False self.is_restore_pending = False
self.last_link_input_text_for_queue_sync = "" self.last_link_input_text_for_queue_sync = ""
# Replace your current reset_application_state with the above.
def _reset_ui_to_defaults(self): def _reset_ui_to_defaults(self):
"""Resets all UI elements and relevant state to their default values.""" """Resets all UI elements and relevant state to their default values."""
# Clear all text fields
self.link_input.clear() self.link_input.clear()
self.custom_folder_input.clear() self.custom_folder_input.clear()
self.character_input.clear() self.character_input.clear()
@@ -4582,8 +4494,6 @@ class DownloaderApp (QWidget ):
self.thread_count_input.setText("4") self.thread_count_input.setText("4")
if hasattr(self, 'manga_date_prefix_input'): if hasattr(self, 'manga_date_prefix_input'):
self.manga_date_prefix_input.clear() self.manga_date_prefix_input.clear()
# Set radio buttons and checkboxes to defaults
self.radio_all.setChecked(True) self.radio_all.setChecked(True)
self.skip_zip_checkbox.setChecked(True) self.skip_zip_checkbox.setChecked(True)
self.download_thumbnails_checkbox.setChecked(False) self.download_thumbnails_checkbox.setChecked(False)
@@ -4605,8 +4515,6 @@ class DownloaderApp (QWidget ):
self.selected_cookie_filepath = None self.selected_cookie_filepath = None
if hasattr(self, 'cookie_text_input'): if hasattr(self, 'cookie_text_input'):
self.cookie_text_input.clear() self.cookie_text_input.clear()
# Reset log and progress displays
if self.main_log_output: if self.main_log_output:
self.main_log_output.clear() self.main_log_output.clear()
if self.external_log_output: if self.external_log_output:
@@ -4615,8 +4523,6 @@ class DownloaderApp (QWidget ):
self.missed_character_log_output.clear() self.missed_character_log_output.clear()
self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle")) self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle"))
self.file_progress_label.setText("") self.file_progress_label.setText("")
# Reset internal state
self.missed_title_key_terms_count.clear() self.missed_title_key_terms_count.clear()
self.missed_title_key_terms_examples.clear() self.missed_title_key_terms_examples.clear()
self.logged_summary_for_key_term.clear() self.logged_summary_for_key_term.clear()
@@ -4647,8 +4553,6 @@ class DownloaderApp (QWidget ):
self._current_link_post_title = None self._current_link_post_title = None
if self.download_extracted_links_button: if self.download_extracted_links_button:
self.download_extracted_links_button.setEnabled(False) self.download_extracted_links_button.setEnabled(False)
# Reset favorite/queue/session state
self.favorite_download_queue.clear() self.favorite_download_queue.clear()
self.is_processing_favorites_queue = False self.is_processing_favorites_queue = False
self.current_processing_favorite_item_info = None self.current_processing_favorite_item_info = None
@@ -4656,14 +4560,11 @@ class DownloaderApp (QWidget ):
self.is_restore_pending = False self.is_restore_pending = False
self.last_link_input_text_for_queue_sync = "" self.last_link_input_text_for_queue_sync = ""
self._update_button_states_and_connections() self._update_button_states_and_connections()
# Reset counters and progress
self.total_posts_to_process = 0 self.total_posts_to_process = 0
self.processed_posts_count = 0 self.processed_posts_count = 0
self.download_counter = 0 self.download_counter = 0
self.skip_counter = 0 self.skip_counter = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
# Reset log view and UI state
if self.log_view_stack: if self.log_view_stack:
self.log_view_stack.setCurrentIndex(0) self.log_view_stack.setCurrentIndex(0)
if self.progress_log_label: if self.progress_log_label:
@@ -4671,27 +4572,19 @@ class DownloaderApp (QWidget ):
if self.log_verbosity_toggle_button: if self.log_verbosity_toggle_button:
self.log_verbosity_toggle_button.setText(self.EYE_ICON) 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.") 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("") self.filter_character_list("")
# Update UI for manga mode and multithreading
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
self.update_ui_for_manga_mode(False) self.update_ui_for_manga_mode(False)
self.update_custom_folder_visibility(self.link_input.text()) self.update_custom_folder_visibility(self.link_input.text())
self.update_page_range_enabled_state() self.update_page_range_enabled_state()
self._update_cookie_input_visibility(False) self._update_cookie_input_visibility(False)
self._update_cookie_input_placeholders_and_tooltips() self._update_cookie_input_placeholders_and_tooltips()
# Reset button states
self.download_btn.setEnabled(True) self.download_btn.setEnabled(True)
self.cancel_btn.setEnabled(False) self.cancel_btn.setEnabled(False)
if self.reset_button: if self.reset_button:
self.reset_button.setEnabled(True) self.reset_button.setEnabled(True)
self.reset_button.setText(self._tr("reset_button_text", "🔄 Reset")) 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).")) 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'): if hasattr(self, 'favorite_mode_checkbox'):
self._handle_favorite_mode_toggle(False) self._handle_favorite_mode_toggle(False)
if hasattr(self, 'scan_content_images_checkbox'): if hasattr(self, 'scan_content_images_checkbox'):
@@ -4887,8 +4780,6 @@ class DownloaderApp (QWidget ):
self._tr("restore_pending_message_creator_selection", self._tr("restore_pending_message_creator_selection",
"Please 'Restore Download' or 'Discard Session' before selecting new creators.")) "Please 'Restore Download' or 'Discard Session' before selecting new creators."))
return return
# Correctly create the dialog instance
dialog = EmptyPopupDialog(self.app_base_dir, self) dialog = EmptyPopupDialog(self.app_base_dir, self)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: