3 Commits

Author SHA1 Message Date
Yuvi9587
fbdae61b80 Commit 2025-07-19 03:28:32 -07:00
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
19 changed files with 874 additions and 957 deletions

View File

@@ -72,7 +72,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
# --- File Type Extensions --- # --- File Type Extensions ---
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

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,12 +141,10 @@ 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)
@@ -178,13 +153,20 @@ class DownloadManager:
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
@@ -74,7 +66,7 @@ class PostProcessorWorker:
def __init__(self, post_data, download_root, known_names, def __init__(self, post_data, download_root, known_names,
filter_character_list, emitter, filter_character_list, emitter,
unwanted_keywords, filter_mode, skip_zip, skip_rar, unwanted_keywords, filter_mode, skip_zip,
use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name, use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
compress_images, download_thumbnails, service, user_id, pause_event, compress_images, download_thumbnails, service, user_id, pause_event,
api_url_input, cancellation_event, api_url_input, cancellation_event,
@@ -121,7 +113,6 @@ class PostProcessorWorker:
self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set() self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
self.filter_mode = filter_mode self.filter_mode = filter_mode
self.skip_zip = skip_zip self.skip_zip = skip_zip
self.skip_rar = skip_rar
self.use_subfolders = use_subfolders self.use_subfolders = use_subfolders
self.use_post_subfolders = use_post_subfolders self.use_post_subfolders = use_post_subfolders
self.target_post_id_from_initial_url = target_post_id_from_initial_url self.target_post_id_from_initial_url = target_post_id_from_initial_url
@@ -394,13 +385,9 @@ class PostProcessorWorker:
if not is_audio_type: if not is_audio_type:
self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).") self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
if self.skip_zip and is_zip(api_original_filename): if (self.skip_zip) and is_archive(api_original_filename):
self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).") self.logger(f" -> Pref Skip: '{api_original_filename}' (Archive).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
if self.skip_rar and is_rar(api_original_filename):
self.logger(f" -> Pref Skip: '{api_original_filename}' (RAR).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
try: try:
os.makedirs(target_folder_path, exist_ok=True) os.makedirs(target_folder_path, exist_ok=True)
except OSError as e: except OSError as e:
@@ -568,15 +555,12 @@ class PostProcessorWorker:
if self._check_pause(f"Post-download hash check for '{api_original_filename}'"): if self._check_pause(f"Post-download hash check for '{api_original_filename}'"):
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
# --- Final Corrected Duplicate Handling Logic ---
should_skip = False should_skip = False
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
@@ -587,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):
@@ -684,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
@@ -695,6 +682,7 @@ class PostProcessorWorker:
def process(self): def process(self):
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
try: try:
if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"): if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"):
@@ -729,7 +717,8 @@ class PostProcessorWorker:
effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words) effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words)
post_content_html = post_data.get('content', '') post_content_html = post_data.get('content', '')
self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---") if not self.extract_links_only:
self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---")
num_potential_files_in_post = len(post_attachments or []) + (1 if post_main_file_info and post_main_file_info.get('path') else 0) num_potential_files_in_post = len(post_attachments or []) + (1 if post_main_file_info and post_main_file_info.get('path') else 0)
post_is_candidate_by_title_char_match = False post_is_candidate_by_title_char_match = False
@@ -1044,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)
@@ -1054,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':
@@ -1064,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"
@@ -1111,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}"
@@ -1129,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...")
@@ -1160,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)
@@ -1264,13 +1293,9 @@ class PostProcessorWorker:
else: else:
self.logger(f" ⚠️ Skipping invalid attachment {idx + 1} for post {post_id}: {str(att_info)[:100]}") self.logger(f" ⚠️ Skipping invalid attachment {idx + 1} for post {post_id}: {str(att_info)[:100]}")
# --- START: Conditionally de-duplicate files from API response ---
# Only de-duplicate by URL if we are in the default hash-skipping mode.
# If the user wants to keep everything, we must process all entries from the API.
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
@@ -1281,7 +1306,6 @@ class PostProcessorWorker:
if new_count < original_count: if new_count < original_count:
self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the API response.") self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the API response.")
# --- END: Conditionally de-duplicate files from API response ---
if self.scan_content_for_images and post_content_html and not self.extract_links_only: if self.scan_content_for_images and post_content_html and not self.extract_links_only:
self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...") self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...")
@@ -1614,7 +1638,7 @@ class DownloadThread(QThread):
def __init__(self, api_url_input, output_dir, known_names_copy, def __init__(self, api_url_input, output_dir, known_names_copy,
cancellation_event, cancellation_event,
pause_event, filter_character_list=None, dynamic_character_filter_holder=None, pause_event, filter_character_list=None, dynamic_character_filter_holder=None,
filter_mode='all', skip_zip=True, skip_rar=True, filter_mode='all', skip_zip=True,
use_subfolders=True, use_post_subfolders=False, custom_folder_name=None, compress_images=False, use_subfolders=True, use_post_subfolders=False, custom_folder_name=None, compress_images=False,
download_thumbnails=False, service=None, user_id=None, download_thumbnails=False, service=None, user_id=None,
downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None, downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None,
@@ -1654,7 +1678,8 @@ class DownloadThread(QThread):
text_export_format='txt', text_export_format='txt',
single_pdf_mode=False, single_pdf_mode=False,
project_root_dir=None, project_root_dir=None,
processed_post_ids=None): processed_post_ids=None,
start_offset=0):
super().__init__() super().__init__()
self.api_url_input = api_url_input self.api_url_input = api_url_input
self.output_dir = output_dir self.output_dir = output_dir
@@ -1667,7 +1692,6 @@ class DownloadThread(QThread):
self.dynamic_filter_holder = dynamic_character_filter_holder self.dynamic_filter_holder = dynamic_character_filter_holder
self.filter_mode = filter_mode self.filter_mode = filter_mode
self.skip_zip = skip_zip self.skip_zip = skip_zip
self.skip_rar = skip_rar
self.use_subfolders = use_subfolders self.use_subfolders = use_subfolders
self.use_post_subfolders = use_post_subfolders self.use_post_subfolders = use_post_subfolders
self.custom_folder_name = custom_folder_name self.custom_folder_name = custom_folder_name
@@ -1717,7 +1741,8 @@ class DownloadThread(QThread):
self.text_export_format = text_export_format self.text_export_format = text_export_format
self.single_pdf_mode = single_pdf_mode self.single_pdf_mode = single_pdf_mode
self.project_root_dir = project_root_dir self.project_root_dir = project_root_dir
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else [] self.processed_post_ids_set = set(processed_post_ids) if processed_post_ids is not None else set()
self.start_offset = start_offset
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
@@ -1730,7 +1755,9 @@ class DownloadThread(QThread):
def run(self): def run(self):
""" """
The main execution method for the single-threaded download process. The main execution method for the download process.
This version correctly uses the central `download_from_api` function
and explicitly maps all arguments to the PostProcessorWorker to prevent TypeErrors.
""" """
grand_total_downloaded_files = 0 grand_total_downloaded_files = 0
grand_total_skipped_files = 0 grand_total_skipped_files = 0
@@ -1739,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)
@@ -1749,6 +1775,7 @@ class DownloadThread(QThread):
worker_signals_obj.worker_finished_signal.connect(lambda result: None) worker_signals_obj.worker_finished_signal.connect(lambda result: None)
self.logger(" Starting post fetch (single-threaded download process)...") self.logger(" Starting post fetch (single-threaded download process)...")
post_generator = download_from_api( post_generator = download_from_api(
self.api_url_input, self.api_url_input,
logger=self.logger, logger=self.logger,
@@ -1762,118 +1789,111 @@ class DownloadThread(QThread):
selected_cookie_file=self.selected_cookie_file, selected_cookie_file=self.selected_cookie_file,
app_base_dir=self.app_base_dir, app_base_dir=self.app_base_dir,
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None, manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
# --- FIX: ADDED A COMMA to the line above --- processed_post_ids=self.processed_post_ids_set
processed_post_ids=self.processed_post_ids
) )
for posts_batch_data in post_generator: for posts_batch_data in post_generator:
if self.isInterruptionRequested(): if self.isInterruptionRequested():
was_process_cancelled = True was_process_cancelled = True
break break
for individual_post_data in posts_batch_data: for individual_post_data in posts_batch_data:
if self.isInterruptionRequested(): if self.isInterruptionRequested():
was_process_cancelled = True was_process_cancelled = True
break break
post_processing_worker = PostProcessorWorker( worker_args = {
post_data=individual_post_data, 'post_data': individual_post_data,
download_root=self.output_dir, 'emitter': worker_signals_obj,
known_names=self.known_names, 'download_root': self.output_dir,
filter_character_list=self.filter_character_list_objects_initial, 'known_names': self.known_names,
dynamic_character_filter_holder=self.dynamic_filter_holder, 'filter_character_list': self.filter_character_list_objects_initial,
unwanted_keywords=self.unwanted_keywords, 'dynamic_character_filter_holder': self.dynamic_filter_holder,
filter_mode=self.filter_mode, 'target_post_id_from_initial_url': self.initial_target_post_id,
skip_zip=self.skip_zip, skip_rar=self.skip_rar, 'num_file_threads': self.num_file_threads_for_worker,
use_subfolders=self.use_subfolders, use_post_subfolders=self.use_post_subfolders, 'processed_post_ids': list(self.processed_post_ids_set),
target_post_id_from_initial_url=self.initial_target_post_id, 'unwanted_keywords': self.unwanted_keywords,
custom_folder_name=self.custom_folder_name, 'filter_mode': self.filter_mode,
compress_images=self.compress_images, download_thumbnails=self.download_thumbnails, 'skip_zip': self.skip_zip,
service=self.service, user_id=self.user_id, 'use_subfolders': self.use_subfolders,
api_url_input=self.api_url_input, 'use_post_subfolders': self.use_post_subfolders,
pause_event=self.pause_event, 'custom_folder_name': self.custom_folder_name,
cancellation_event=self.cancellation_event, 'compress_images': self.compress_images,
emitter=worker_signals_obj, 'download_thumbnails': self.download_thumbnails,
downloaded_files=self.downloaded_files, 'service': self.service,
downloaded_file_hashes=self.downloaded_file_hashes, 'user_id': self.user_id,
downloaded_files_lock=self.downloaded_files_lock, 'api_url_input': self.api_url_input,
downloaded_file_hashes_lock=self.downloaded_file_hashes_lock, 'pause_event': self.pause_event,
skip_words_list=self.skip_words_list, 'cancellation_event': self.cancellation_event,
skip_words_scope=self.skip_words_scope, 'downloaded_files': self.downloaded_files,
show_external_links=self.show_external_links, 'downloaded_file_hashes': self.downloaded_file_hashes,
extract_links_only=self.extract_links_only, 'downloaded_files_lock': self.downloaded_files_lock,
num_file_threads=self.num_file_threads_for_worker, 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
skip_current_file_flag=self.skip_current_file_flag, 'skip_words_list': self.skip_words_list,
manga_mode_active=self.manga_mode_active, 'skip_words_scope': self.skip_words_scope,
manga_filename_style=self.manga_filename_style, 'show_external_links': self.show_external_links,
manga_date_prefix=self.manga_date_prefix, 'extract_links_only': self.extract_links_only,
char_filter_scope=self.char_filter_scope, 'skip_current_file_flag': self.skip_current_file_flag,
remove_from_filename_words_list=self.remove_from_filename_words_list, 'manga_mode_active': self.manga_mode_active,
allow_multipart_download=self.allow_multipart_download, 'manga_filename_style': self.manga_filename_style,
selected_cookie_file=self.selected_cookie_file, 'char_filter_scope': self.char_filter_scope,
app_base_dir=self.app_base_dir, 'remove_from_filename_words_list': self.remove_from_filename_words_list,
cookie_text=self.cookie_text, 'allow_multipart_download': self.allow_multipart_download,
override_output_dir=self.override_output_dir, 'cookie_text': self.cookie_text,
manga_global_file_counter_ref=self.manga_global_file_counter_ref, 'use_cookie': self.use_cookie,
use_cookie=self.use_cookie, 'override_output_dir': self.override_output_dir,
manga_date_file_counter_ref=self.manga_date_file_counter_ref, 'selected_cookie_file': self.selected_cookie_file,
use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder, 'app_base_dir': self.app_base_dir,
keep_in_post_duplicates=self.keep_in_post_duplicates, 'manga_date_prefix': self.manga_date_prefix,
keep_duplicates_mode=self.keep_duplicates_mode, 'manga_date_file_counter_ref': self.manga_date_file_counter_ref,
keep_duplicates_limit=self.keep_duplicates_limit, 'scan_content_for_images': self.scan_content_for_images,
downloaded_hash_counts=self.downloaded_hash_counts, 'creator_download_folder_ignore_words': self.creator_download_folder_ignore_words,
downloaded_hash_counts_lock=self.downloaded_hash_counts_lock, 'manga_global_file_counter_ref': self.manga_global_file_counter_ref,
creator_download_folder_ignore_words=self.creator_download_folder_ignore_words, 'use_date_prefix_for_subfolder': self.use_date_prefix_for_subfolder,
session_file_path=self.session_file_path, 'keep_in_post_duplicates': self.keep_in_post_duplicates,
session_lock=self.session_lock, 'keep_duplicates_mode': self.keep_duplicates_mode,
text_only_scope=self.text_only_scope, 'keep_duplicates_limit': self.keep_duplicates_limit,
text_export_format=self.text_export_format, 'downloaded_hash_counts': self.downloaded_hash_counts,
single_pdf_mode=self.single_pdf_mode, 'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
project_root_dir=self.project_root_dir 'session_file_path': self.session_file_path,
) 'session_lock': self.session_lock,
try: 'text_only_scope': self.text_only_scope,
(dl_count, skip_count, kept_originals_this_post, 'text_export_format': self.text_export_format,
retryable_failures, permanent_failures, 'single_pdf_mode': self.single_pdf_mode,
history_data, temp_filepath) = post_processing_worker.process() 'project_root_dir': self.project_root_dir,
}
grand_total_downloaded_files += dl_count post_processing_worker = PostProcessorWorker(**worker_args)
grand_total_skipped_files += skip_count
if kept_originals_this_post: (dl_count, skip_count, kept_originals_this_post,
grand_list_of_kept_original_filenames.extend(kept_originals_this_post) retryable_failures, permanent_failures,
if retryable_failures: history_data, temp_filepath) = post_processing_worker.process()
self.retryable_file_failed_signal.emit(retryable_failures)
if history_data:
if len(self.history_candidates_buffer) < 8:
self.post_processed_for_history_signal.emit(history_data)
if permanent_failures:
self.permanent_file_failed_signal.emit(permanent_failures)
if self.single_pdf_mode and temp_filepath: grand_total_downloaded_files += dl_count
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}") grand_total_skipped_files += skip_count
if kept_originals_this_post:
grand_list_of_kept_original_filenames.extend(kept_originals_this_post)
if retryable_failures:
self.retryable_file_failed_signal.emit(retryable_failures)
if history_data:
self.post_processed_for_history_signal.emit(history_data)
if permanent_failures:
self.permanent_file_failed_signal.emit(permanent_failures)
if self.single_pdf_mode and temp_filepath:
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}")
except Exception as proc_err:
post_id_for_err = individual_post_data.get('id', 'N/A')
self.logger(f"❌ Error processing post {post_id_for_err} in DownloadThread: {proc_err}")
traceback.print_exc()
num_potential_files_est = len(individual_post_data.get('attachments', [])) + (
1 if individual_post_data.get('file') else 0)
grand_total_skipped_files += num_potential_files_est
if self.skip_current_file_flag and self.skip_current_file_flag.is_set():
self.skip_current_file_flag.clear()
self.logger(" Skip current file flag was processed and cleared by DownloadThread.")
self.msleep(10)
if was_process_cancelled: if was_process_cancelled:
break break
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()
finally: finally:
try: try:
# Disconnect signals
if worker_signals_obj: if worker_signals_obj:
worker_signals_obj.progress_signal.disconnect(self.progress_signal) worker_signals_obj.progress_signal.disconnect(self.progress_signal)
worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal) worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal)
@@ -1884,14 +1904,8 @@ class DownloadThread(QThread):
except (TypeError, RuntimeError) as e: except (TypeError, RuntimeError) as e:
self.logger(f" Note during DownloadThread signal disconnection: {e}") self.logger(f" Note during DownloadThread signal disconnection: {e}")
# Emit the final signal with all collected results
self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames) self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames)
def receive_add_character_result (self ,result ):
with QMutexLocker (self .prompt_mutex ):
self ._add_character_response =result
self .logger (f" (DownloadThread) Received character prompt response: {'Yes (added/confirmed)'if result else 'No (declined/failed)'}")
class InterruptedError(Exception): class InterruptedError(Exception):
"""Custom exception for handling cancellations gracefully.""" """Custom exception for handling cancellations gracefully."""
pass pass

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,7 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
# --- Asset Management ---
# This global variable will cache the icon so we don't have to load it from disk every time.
_app_icon_cache = None _app_icon_cache = None
def get_app_icon_object(): def get_app_icon_object():
@@ -22,17 +16,11 @@ def get_app_icon_object():
if _app_icon_cache and not _app_icon_cache.isNull(): if _app_icon_cache and not _app_icon_cache.isNull():
return _app_icon_cache return _app_icon_cache
# Declare a single variable to hold the base directory path.
app_base_dir = "" app_base_dir = ""
# Determine the project's base directory, whether running from source or as a bundled app
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
# The application is frozen (e.g., with PyInstaller).
# The base directory is the one containing the executable.
app_base_dir = os.path.dirname(sys.executable) app_base_dir = os.path.dirname(sys.executable)
else: else:
# The application is running from a .py file.
# This path navigates up from src/ui/assets.py to the project root.
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico') icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
@@ -40,7 +28,6 @@ def get_app_icon_object():
if os.path.exists(icon_path): if os.path.exists(icon_path):
_app_icon_cache = QIcon(icon_path) _app_icon_cache = QIcon(icon_path)
else: else:
# If the icon isn't found, especially in a frozen app, check the _MEIPASS directory as a fallback.
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico') fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
if os.path.exists(fallback_icon_path): if os.path.exists(fallback_icon_path):

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,17 +1,10 @@
# --- 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 ---
# 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
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
@@ -20,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):
@@ -36,29 +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)
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Set window size dynamically based on the parent window's size base_width, base_height = 600, 450
if parent: self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
parent_width = parent.width() self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
parent_height = parent.height()
# Use a scaling factor for different screen resolutions
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 500, 400
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
max(int(parent_height * 0.7 * scale_factor), 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()
@@ -76,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))
@@ -108,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()
@@ -116,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']})"
@@ -147,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,13 +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)
# Set window size dynamically # --- START OF FIX ---
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 # Get the user-defined scale factor from the parent application.
scale_factor = screen_height / 1080.0 scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_min_w, base_min_h = 500, 300
scaled_min_w = int(base_min_w * scale_factor) # Define base dimensions and apply the correct scale factor.
scaled_min_h = int(base_min_h * scale_factor) base_width, base_height = 550, 400
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming --- # --- 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
@@ -20,15 +15,18 @@ class TourStepWidget(QWidget):
A custom widget representing a single step or page in the feature guide. A custom widget representing a single step or page in the feature guide.
It neatly formats a title and its corresponding content. It neatly formats a title and its corresponding content.
""" """
def __init__(self, title_text, content_text, parent=None): def __init__(self, title_text, content_text, parent=None, scale=1.0):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10) layout.setSpacing(10)
title_font_size = int(14 * scale)
content_font_size = int(11 * scale)
title_label = QLabel(title_text) title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;") title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label) layout.addWidget(title_label)
scroll_area = QScrollArea() scroll_area = QScrollArea()
@@ -42,8 +40,8 @@ class TourStepWidget(QWidget):
content_label.setWordWrap(True) content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText) content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True) # Allow opening links in the content content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label) scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1) layout.addWidget(scroll_area, 1)
@@ -56,27 +54,38 @@ class HelpGuideDialog (QDialog ):
self .steps_data =steps_data self .steps_data =steps_data
self .parent_app =parent_app self .parent_app =parent_app
app_icon =get_app_icon_object () scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
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)
self .setModal (True ) self.setModal(True)
self .setFixedSize (650 ,600 ) self.resize(int(650 * scale), int(600 * scale))
dialog_font_size = int(11 * scale)
current_theme_style ="" current_theme_style = ""
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark": if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
if hasattr (self .parent_app ,'get_dark_theme'): current_theme_style = get_dark_theme(scale)
current_theme_style =self .parent_app .get_dark_theme () else:
current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }}
QPushButton {{
background-color: #E1E1E1;
color: #1E1E1E;
border: 1px solid #ADADAD;
padding: {int(8*scale)}px {int(15*scale)}px;
border-radius: 4px;
min-height: {int(25*scale)}px;
font-size: {dialog_font_size}pt;
}}
QPushButton:hover {{ background-color: #CACACA; }}
QPushButton:pressed {{ background-color: #B0B0B0; }}
"""
self.setStyleSheet(current_theme_style)
self .setStyleSheet (current_theme_style if current_theme_style else """
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
QLabel { color: #E0E0E0; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
QPushButton:hover { background-color: #656565; }
QPushButton:pressed { background-color: #4A4A4A; }
""")
self ._init_ui () self ._init_ui ()
if self .parent_app : if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ()) self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
@@ -97,10 +106,11 @@ class HelpGuideDialog (QDialog ):
main_layout .addWidget (self .stacked_widget ,1 ) main_layout .addWidget (self .stacked_widget ,1 )
self .tour_steps_widgets =[] self .tour_steps_widgets =[]
for title ,content in self .steps_data : scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
step_widget =TourStepWidget (title ,content ) for title, content in self.steps_data:
self .tour_steps_widgets .append (step_widget ) step_widget = TourStepWidget(title, content, scale=scale)
self .stacked_widget .addWidget (step_widget ) self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide")) self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
@@ -115,7 +125,6 @@ class HelpGuideDialog (QDialog ):
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS assets_base_dir =sys ._MEIPASS
else : else :
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png") github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
@@ -126,7 +135,9 @@ class HelpGuideDialog (QDialog ):
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"") self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"") self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
icon_size =QSize (24 ,24 ) scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size ) self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size ) self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size ) self .Discord_button .setIconSize (icon_size )

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

@@ -14,7 +14,6 @@ class KnownNamesFilterDialog(QDialog):
""" """
A dialog to select names from the Known.txt list to add to the main A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads. to reuse their saved names and groups for filtering downloads.
""" """
@@ -38,13 +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)
# Define base size and apply the correct scale factor
base_width, base_height = 460, 450 base_width, base_height = 460, 450
scale_factor_h = screen_geometry.height() / 1080.0 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor)) # --- END OF FIX ---
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
# --- 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', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
except Exception as font_error: except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}") logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
logger(" PDF may not support all characters. Falling back to default Arial font.") default_font_family = 'Arial'
pdf.set_font('Arial', '', 12)
pdf.set_font('Arial', 'B', 16)
logger(f" Starting PDF creation with content from {len(posts_data)} posts...") pdf.add_page()
for post in posts_data: logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
pdf.add_page()
# Post Title
pdf.set_font('DejaVu', 'B', 16)
# vvv THIS LINE IS CORRECTED vvv for i, post in enumerate(posts_data):
# We explicitly set align='L' and remove the incorrect positional arguments. 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.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
pdf.ln(5) # Add a little space after the title 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', ''))
# Post Content pdf.set_font(default_font_family, '', 10)
pdf.set_font('DejaVu', '', 12) pdf.write(8, "Comment by: ")
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) 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)

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ MAX_FILENAME_COMPONENT_LENGTH = 150
# Sets of file extensions for quick type checking # Sets of file extensions for quick type checking
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {

View File

@@ -24,19 +24,14 @@ def setup_ui(main_app):
Args: Args:
main_app: The instance of the main DownloaderApp. main_app: The instance of the main DownloaderApp.
""" """
# --- START: Modified Scaling Logic ---
# Force a fixed scale factor to disable UI scaling on high-DPI screens.
scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0)) scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0))
main_app.scale_factor = scale main_app.scale_factor = scale
# --- Set the global font size for the application ---
default_font = QApplication.font() default_font = QApplication.font()
base_font_size = 9 # Use a standard base size base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale)) default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font) main_app.setFont(default_font)
# --- END: Modified Scaling Logic ---
# --- Set the global font size for the application ---
default_font = QApplication.font() default_font = QApplication.font()
base_font_size = 9 # Use a standard base size base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale)) default_font.setPointSize(int(base_font_size * scale))
@@ -221,12 +216,10 @@ def setup_ui(main_app):
checkboxes_group_layout.setSpacing(10) checkboxes_group_layout.setSpacing(10)
row1_layout = QHBoxLayout() row1_layout = QHBoxLayout()
row1_layout.setSpacing(10) row1_layout.setSpacing(10)
main_app.skip_zip_checkbox = QCheckBox("Skip .zip") main_app.skip_zip_checkbox = QCheckBox("Skip archives")
main_app.skip_zip_checkbox.setToolTip("Skip Common Archives (Eg.. Zip, Rar, 7z)")
main_app.skip_zip_checkbox.setChecked(True) main_app.skip_zip_checkbox.setChecked(True)
row1_layout.addWidget(main_app.skip_zip_checkbox) row1_layout.addWidget(main_app.skip_zip_checkbox)
main_app.skip_rar_checkbox = QCheckBox("Skip .rar")
main_app.skip_rar_checkbox.setChecked(True)
row1_layout.addWidget(main_app.skip_rar_checkbox)
main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
row1_layout.addWidget(main_app.download_thumbnails_checkbox) row1_layout.addWidget(main_app.download_thumbnails_checkbox)
main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images") main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images")
@@ -246,7 +239,7 @@ def setup_ui(main_app):
checkboxes_group_layout.addWidget(advanced_settings_label) checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout() advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10) advanced_row1_layout.setSpacing(10)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title") main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(True) main_app.use_subfolders_checkbox.setChecked(True)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders) main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox) advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)