27 Commits

Author SHA1 Message Date
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
Yuvi9587
8ba2a572fa Update readme.md 2025-07-16 09:51:04 -07:00
Yuvi9587
8db40f03b6 Update readme.md 2025-07-16 09:50:41 -07:00
Yuvi9587
742fe7685c Update readme.md 2025-07-16 09:49:47 -07:00
Yuvi9587
e085d9a134 Update readme.md 2025-07-16 09:49:05 -07:00
Yuvi9587
1cd03731c0 Update readme.md 2025-07-16 09:47:51 -07:00
Yuvi9587
0bc8d7c692 Update readme.md 2025-07-16 09:47:07 -07:00
Yuvi9587
3a9009e76e Update readme.md 2025-07-16 09:45:40 -07:00
Yuvi9587
9a28e922b4 Commit 2025-07-16 09:42:52 -07:00
Yuvi9587
923a0ff61e Update readme.md 2025-07-16 09:41:37 -07:00
Yuvi9587
e891a2a845 Update readme.md 2025-07-16 09:41:18 -07:00
Yuvi9587
778b0219e2 Update readme.md 2025-07-16 09:39:58 -07:00
Yuvi9587
3fc08d9ea7 Commit 2025-07-16 09:39:07 -07:00
Yuvi9587
af6a6add57 Update readme.md 2025-07-16 09:35:30 -07:00
Yuvi9587
7737d32ef9 Update readme.md 2025-07-16 09:34:22 -07:00
Yuvi9587
c08cbb6490 Update readme.md 2025-07-16 09:30:43 -07:00
Yuvi9587
92a2e91624 Update readme.md 2025-07-16 09:29:46 -07:00
Yuvi9587
11ea511a9d Update readme.md 2025-07-16 09:28:48 -07:00
Yuvi9587
8abdb49ed8 Update readme.md 2025-07-16 09:27:51 -07:00
Yuvi9587
0873dd1ce0 Update readme.md 2025-07-16 09:27:26 -07:00
Yuvi9587
df5fbc1f73 Update readme.md 2025-07-16 09:25:51 -07:00
Yuvi9587
5510f7f0c6 Update readme.md 2025-07-16 09:25:29 -07:00
Yuvi9587
2f0593c450 Update readme.md 2025-07-16 09:23:27 -07:00
Yuvi9587
e67adb6bdc Update readme.md 2025-07-16 09:23:02 -07:00
Yuvi9587
d39081088c Update FUNDING.yml 2025-07-16 09:21:06 -07:00
Yuvi9587
f303b8b020 Commit 2025-07-16 09:02:47 -07:00
17 changed files with 1185 additions and 777 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1,3 @@
github: [Yuvi9587] github: [Yuvi9587]
ko_fi: yuvi427183
buy_me_a_coffee: yuvi9587

BIN
Read/bmac.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

@@ -41,6 +41,7 @@ Built with PyQt5, this tool is designed for users who want deep filtering capabi
</div> </div>
--- ---
## Feature Overview ## Feature Overview
@@ -208,4 +209,9 @@ This project is under the Custom Licence
</a> </a>
</table> </table>
👉 See [features.md](features.md) for the full feature list. <p align="center">
<a href="https://buymeacoffee.com/yuvi9587">
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
</a>
</p>

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 = {
@@ -113,3 +113,7 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
"fri", "friday", "sat", "saturday", "sun", "sunday" "fri", "friday", "sat", "saturday", "sun", "sunday"
# add more according to need # add more according to need
} }
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

View File

@@ -9,7 +9,7 @@ import uuid
import http import http
import html import html
import json import json
from collections import deque from collections import deque, defaultdict
import hashlib import hashlib
from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future
from io import BytesIO from io import BytesIO
@@ -71,93 +71,101 @@ class PostProcessorSignals (QObject ):
worker_finished_signal = pyqtSignal(tuple) worker_finished_signal = pyqtSignal(tuple)
class PostProcessorWorker: class PostProcessorWorker:
def __init__ (self ,post_data ,download_root ,known_names ,
filter_character_list ,emitter , def __init__(self, post_data, download_root, known_names,
unwanted_keywords ,filter_mode ,skip_zip ,skip_rar , filter_character_list, emitter,
use_subfolders ,use_post_subfolders ,target_post_id_from_initial_url ,custom_folder_name , unwanted_keywords, filter_mode, skip_zip,
compress_images ,download_thumbnails ,service ,user_id ,pause_event , use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
api_url_input ,cancellation_event , compress_images, download_thumbnails, service, user_id, pause_event,
downloaded_files ,downloaded_file_hashes ,downloaded_files_lock ,downloaded_file_hashes_lock , api_url_input, cancellation_event,
dynamic_character_filter_holder =None ,skip_words_list =None , downloaded_files, downloaded_file_hashes, downloaded_files_lock, downloaded_file_hashes_lock,
skip_words_scope =SKIP_SCOPE_FILES , dynamic_character_filter_holder=None, skip_words_list=None,
show_external_links =False , skip_words_scope=SKIP_SCOPE_FILES,
extract_links_only =False , show_external_links=False,
num_file_threads =4 ,skip_current_file_flag =None , extract_links_only=False,
manga_mode_active =False , num_file_threads=4, skip_current_file_flag=None,
manga_filename_style =STYLE_POST_TITLE , manga_mode_active=False,
char_filter_scope =CHAR_SCOPE_FILES , manga_filename_style=STYLE_POST_TITLE,
remove_from_filename_words_list =None , char_filter_scope=CHAR_SCOPE_FILES,
allow_multipart_download =True , remove_from_filename_words_list=None,
cookie_text ="", allow_multipart_download=True,
use_cookie =False , cookie_text="",
override_output_dir =None , use_cookie=False,
selected_cookie_file =None , override_output_dir=None,
app_base_dir =None , selected_cookie_file=None,
manga_date_prefix =MANGA_DATE_PREFIX_DEFAULT , app_base_dir=None,
manga_date_file_counter_ref =None , manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT,
scan_content_for_images =False , manga_date_file_counter_ref=None,
creator_download_folder_ignore_words =None , scan_content_for_images=False,
manga_global_file_counter_ref =None , creator_download_folder_ignore_words=None,
use_date_prefix_for_subfolder=False, manga_global_file_counter_ref=None,
keep_in_post_duplicates=False, use_date_prefix_for_subfolder=False,
session_file_path=None, keep_in_post_duplicates=False,
session_lock=None, keep_duplicates_mode=DUPLICATE_HANDLING_HASH,
text_only_scope=None, keep_duplicates_limit=0,
text_export_format='txt', downloaded_hash_counts=None,
single_pdf_mode=False, downloaded_hash_counts_lock=None,
project_root_dir=None, session_file_path=None,
processed_post_ids=None session_lock=None,
): text_only_scope=None,
self .post =post_data text_export_format='txt',
self .download_root =download_root single_pdf_mode=False,
self .known_names =known_names project_root_dir=None,
self .filter_character_list_objects_initial =filter_character_list if filter_character_list else [] processed_post_ids=None
self .dynamic_filter_holder =dynamic_character_filter_holder ):
self .unwanted_keywords =unwanted_keywords if unwanted_keywords is not None else set () self.post = post_data
self .filter_mode =filter_mode self.download_root = download_root
self .skip_zip =skip_zip self.known_names = known_names
self .skip_rar =skip_rar self.filter_character_list_objects_initial = filter_character_list if filter_character_list else []
self .use_subfolders =use_subfolders self.dynamic_filter_holder = dynamic_character_filter_holder
self .use_post_subfolders =use_post_subfolders self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
self .target_post_id_from_initial_url =target_post_id_from_initial_url self.filter_mode = filter_mode
self .custom_folder_name =custom_folder_name self.skip_zip = skip_zip
self .compress_images =compress_images self.use_subfolders = use_subfolders
self .download_thumbnails =download_thumbnails self.use_post_subfolders = use_post_subfolders
self .service =service self.target_post_id_from_initial_url = target_post_id_from_initial_url
self .user_id =user_id self.custom_folder_name = custom_folder_name
self .api_url_input =api_url_input self.compress_images = compress_images
self .cancellation_event =cancellation_event self.download_thumbnails = download_thumbnails
self .pause_event =pause_event self.service = service
self .emitter =emitter self.user_id = user_id
if not self .emitter : self.api_url_input = api_url_input
raise ValueError ("PostProcessorWorker requires an emitter (signals object or queue).") self.cancellation_event = cancellation_event
self .skip_current_file_flag =skip_current_file_flag self.pause_event = pause_event
self .downloaded_files =downloaded_files if downloaded_files is not None else set () self.emitter = emitter
self .downloaded_file_hashes =downloaded_file_hashes if downloaded_file_hashes is not None else set () if not self.emitter:
self .downloaded_files_lock =downloaded_files_lock if downloaded_files_lock is not None else threading .Lock () raise ValueError("PostProcessorWorker requires an emitter (signals object or queue).")
self .downloaded_file_hashes_lock =downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading .Lock () self.skip_current_file_flag = skip_current_file_flag
self .skip_words_list =skip_words_list if skip_words_list is not None else [] self.downloaded_files = downloaded_files if downloaded_files is not None else set()
self .skip_words_scope =skip_words_scope self.downloaded_file_hashes = downloaded_file_hashes if downloaded_file_hashes is not None else set()
self .show_external_links =show_external_links self.downloaded_files_lock = downloaded_files_lock if downloaded_files_lock is not None else threading.Lock()
self .extract_links_only =extract_links_only self.downloaded_file_hashes_lock = downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading.Lock()
self .num_file_threads =num_file_threads self.skip_words_list = skip_words_list if skip_words_list is not None else []
self .manga_mode_active =manga_mode_active self.skip_words_scope = skip_words_scope
self .manga_filename_style =manga_filename_style self.show_external_links = show_external_links
self .char_filter_scope =char_filter_scope self.extract_links_only = extract_links_only
self .remove_from_filename_words_list =remove_from_filename_words_list if remove_from_filename_words_list is not None else [] self.num_file_threads = num_file_threads
self .allow_multipart_download =allow_multipart_download self.manga_mode_active = manga_mode_active
self .manga_date_file_counter_ref =manga_date_file_counter_ref self.manga_filename_style = manga_filename_style
self .selected_cookie_file =selected_cookie_file self.char_filter_scope = char_filter_scope
self .app_base_dir =app_base_dir self.remove_from_filename_words_list = remove_from_filename_words_list if remove_from_filename_words_list is not None else []
self .cookie_text =cookie_text self.allow_multipart_download = allow_multipart_download
self .manga_date_prefix =manga_date_prefix self.manga_date_file_counter_ref = manga_date_file_counter_ref
self .manga_global_file_counter_ref =manga_global_file_counter_ref self.selected_cookie_file = selected_cookie_file
self .use_cookie =use_cookie self.app_base_dir = app_base_dir
self .override_output_dir =override_output_dir self.cookie_text = cookie_text
self .scan_content_for_images =scan_content_for_images self.manga_date_prefix = manga_date_prefix
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words self.manga_global_file_counter_ref = manga_global_file_counter_ref
self.use_cookie = use_cookie
self.override_output_dir = override_output_dir
self.scan_content_for_images = scan_content_for_images
self.creator_download_folder_ignore_words = creator_download_folder_ignore_words
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
self.keep_in_post_duplicates = keep_in_post_duplicates self.keep_in_post_duplicates = keep_in_post_duplicates
self.keep_duplicates_mode = keep_duplicates_mode
self.keep_duplicates_limit = keep_duplicates_limit
self.downloaded_hash_counts = downloaded_hash_counts if downloaded_hash_counts is not None else defaultdict(int)
self.downloaded_hash_counts_lock = downloaded_hash_counts_lock if downloaded_hash_counts_lock is not None else threading.Lock()
self.session_file_path = session_file_path self.session_file_path = session_file_path
self.session_lock = session_lock self.session_lock = session_lock
self.text_only_scope = text_only_scope self.text_only_scope = text_only_scope
@@ -166,10 +174,10 @@ class PostProcessorWorker:
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 = processed_post_ids if processed_post_ids is not None else []
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.")
self.compress_images = False
self .logger ("⚠️ Image compression disabled: Pillow library not found.")
self .compress_images =False
def _emit_signal (self ,signal_type_str ,*payload_args ): def _emit_signal (self ,signal_type_str ,*payload_args ):
"""Helper to emit signal either directly or via queue.""" """Helper to emit signal either directly or via queue."""
if isinstance (self .emitter ,queue .Queue ): if isinstance (self .emitter ,queue .Queue ):
@@ -179,6 +187,7 @@ class PostProcessorWorker:
signal_attr .emit (*payload_args ) signal_attr .emit (*payload_args )
else : else :
print (f"(Worker Log - Unrecognized Emitter for {signal_type_str }): {payload_args [0 ]if payload_args else ''}") print (f"(Worker Log - Unrecognized Emitter for {signal_type_str }): {payload_args [0 ]if payload_args else ''}")
def logger (self ,message ): def logger (self ,message ):
self ._emit_signal ('progress',message ) self ._emit_signal ('progress',message )
def check_cancel (self ): def check_cancel (self ):
@@ -384,13 +393,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:
@@ -408,6 +413,7 @@ class PostProcessorWorker:
total_size_bytes = 0 total_size_bytes = 0
download_successful_flag = False download_successful_flag = False
last_exception_for_retry_later = None last_exception_for_retry_later = None
is_permanent_error = False
data_to_write_io = None data_to_write_io = None
response_for_this_attempt = None response_for_this_attempt = None
@@ -512,12 +518,14 @@ class PostProcessorWorker:
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}") self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
last_exception_for_retry_later = e last_exception_for_retry_later = e
is_permanent_error = True
if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.") self.logger(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
break break
except Exception as e: except Exception as e:
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}") self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
last_exception_for_retry_later = e last_exception_for_retry_later = e
is_permanent_error = True
break break
finally: finally:
if response_for_this_attempt: if response_for_this_attempt:
@@ -544,7 +552,6 @@ class PostProcessorWorker:
self.logger(f" ⚠️ Failed to rescue file despite matching size. Error: {rescue_exc}") self.logger(f" ⚠️ Failed to rescue file despite matching size. Error: {rescue_exc}")
if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag): if self.check_cancel() or (skip_event and skip_event.is_set()) or (self.pause_event and self.pause_event.is_set() and not download_successful_flag):
self.logger(f" ⚠️ Download process interrupted for {api_original_filename}.")
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):
try: try:
os.remove(downloaded_part_file_path) os.remove(downloaded_part_file_path)
@@ -556,20 +563,38 @@ 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
### START OF CHANGE 1: INSERT THIS NEW BLOCK ### should_skip = False
with self.downloaded_file_hashes_lock: with self.downloaded_hash_counts_lock:
if calculated_file_hash in self.downloaded_file_hashes: current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
self.logger(f" -> Skip (Content Duplicate): '{api_original_filename}' is identical to a file already downloaded. Discarding.")
# Clean up the downloaded temporary file as it's a duplicate. # Default to not skipping
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): decision_to_skip = False
try:
os.remove(downloaded_part_file_path) # Apply logic based on mode
except OSError: if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
pass if current_count >= 1:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None decision_to_skip = True
self.logger(f" -> Skip (Content Duplicate): '{api_original_filename}' is identical to a file already downloaded. Discarding.")
elif self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL and self.keep_duplicates_limit > 0:
if current_count >= self.keep_duplicates_limit:
decision_to_skip = True
self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.")
# If we are NOT skipping this file, we MUST increment the count.
if not decision_to_skip:
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
should_skip = decision_to_skip
# --- End of Final Corrected Logic ---
if should_skip:
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try:
os.remove(downloaded_part_file_path)
except OSError: pass
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
# If the content is unique, we proceed to save.
# Now, handle FILENAME collisions by adding a numeric suffix if needed.
effective_save_folder = target_folder_path effective_save_folder = target_folder_path
base_name, extension = os.path.splitext(filename_to_save_in_main_path) base_name, extension = os.path.splitext(filename_to_save_in_main_path)
counter = 1 counter = 1
@@ -603,8 +628,6 @@ class PostProcessorWorker:
with self.downloaded_file_hashes_lock: with self.downloaded_file_hashes_lock:
self.downloaded_file_hashes.add(calculated_file_hash) self.downloaded_file_hashes.add(calculated_file_hash)
with self.downloaded_files_lock:
self.downloaded_files.add(final_filename_on_disk)
final_filename_saved_for_return = final_filename_on_disk final_filename_saved_for_return = final_filename_on_disk
self.logger(f"✅ Saved: '{final_filename_saved_for_return}' (from '{api_original_filename}', {downloaded_size_bytes / (1024 * 1024):.2f} MB) in '{os.path.basename(effective_save_folder)}'") self.logger(f"✅ Saved: '{final_filename_saved_for_return}' (from '{api_original_filename}', {downloaded_size_bytes / (1024 * 1024):.2f} MB) in '{os.path.basename(effective_save_folder)}'")
@@ -629,15 +652,12 @@ class PostProcessorWorker:
except Exception as save_err: except Exception as save_err:
self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}") self.logger(f"->>Save Fail for '{final_filename_on_disk}': {save_err}")
# --- START OF THE FIX ---
# If saving/renaming fails, try to clean up the orphaned .part file.
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):
try: try:
os.remove(downloaded_part_file_path) os.remove(downloaded_part_file_path)
self.logger(f" Cleaned up temporary file after save error: {os.path.basename(downloaded_part_file_path)}") self.logger(f" Cleaned up temporary file after save error: {os.path.basename(downloaded_part_file_path)}")
except OSError as e_rem: except OSError as e_rem:
self.logger(f" ⚠️ Could not clean up temporary file '{os.path.basename(downloaded_part_file_path)}' after save error: {e_rem}") self.logger(f" ⚠️ Could not clean up temporary file '{os.path.basename(downloaded_part_file_path)}' after save error: {e_rem}")
# --- END OF THE FIX ---
if os.path.exists(final_save_path): if os.path.exists(final_save_path):
try: try:
@@ -656,22 +676,25 @@ class PostProcessorWorker:
if data_to_write_io and hasattr(data_to_write_io, 'close'): if data_to_write_io and hasattr(data_to_write_io, 'close'):
data_to_write_io.close() data_to_write_io.close()
else: else:
# This is the path if the download was not successful after all retries
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.")
retry_later_details = { details_for_failure = {
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers, 'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post
} }
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details 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
else:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
def process(self): def process(self):
# Default "empty" result tuple. It will be updated before any return path.
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')}"):
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
return result_tuple # Return for the direct caller return result_tuple
if self.check_cancel(): if self.check_cancel():
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
return result_tuple return result_tuple
@@ -701,7 +724,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
@@ -1236,6 +1260,21 @@ 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]}")
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
unique_files_by_url = {}
for file_info in all_files_from_post_api:
# Use the file URL as a unique key to avoid processing the same file multiple times
file_url = file_info.get('url')
if file_url and file_url not in unique_files_by_url:
unique_files_by_url[file_url] = file_info
original_count = len(all_files_from_post_api)
all_files_from_post_api = list(unique_files_by_url.values())
new_count = len(all_files_from_post_api)
if new_count < original_count:
self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the 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})...")
parsed_input_url = urlparse(self.api_url_input) parsed_input_url = urlparse(self.api_url_input)
@@ -1528,9 +1567,7 @@ class PostProcessorWorker:
'service': self.service, 'user_id': self.user_id, 'service': self.service, 'user_id': self.user_id,
} }
if self.check_cancel(): if not self.check_cancel():
self.logger(f" Post {post_id} processing interrupted/cancelled.")
else:
self.logger(f" Post {post_id} Summary: Downloaded={total_downloaded_this_post}, Skipped Files={total_skipped_this_post}") self.logger(f" Post {post_id} Summary: Downloaded={total_downloaded_this_post}, Skipped Files={total_skipped_this_post}")
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0: if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
@@ -1542,18 +1579,14 @@ class PostProcessorWorker:
except OSError as e_rmdir: except OSError as e_rmdir:
self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}") self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
# After all processing, set the final result tuple for the normal execution path
result_tuple = (total_downloaded_this_post, total_skipped_this_post, result_tuple = (total_downloaded_this_post, total_skipped_this_post,
kept_original_filenames_for_log, retryable_failures_this_post, kept_original_filenames_for_log, retryable_failures_this_post,
permanent_failures_this_post, history_data_for_this_post, permanent_failures_this_post, history_data_for_this_post,
None) None)
finally: finally:
# This block is GUARANTEED to execute, sending the signal for multi-threaded mode.
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
# This line is the critical fix. It ensures the method always returns a tuple
# for the single-threaded mode that directly calls it.
return result_tuple return result_tuple
class DownloadThread(QThread): class DownloadThread(QThread):
@@ -1573,12 +1606,12 @@ 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,
skip_words_list=None, skip_words_list=None,
skip_words_scope=SKIP_SCOPE_FILES, skip_words_scope='files',
show_external_links=False, show_external_links=False,
extract_links_only=False, extract_links_only=False,
num_file_threads_for_worker=1, num_file_threads_for_worker=1,
@@ -1587,10 +1620,10 @@ class DownloadThread(QThread):
target_post_id_from_initial_url=None, target_post_id_from_initial_url=None,
manga_mode_active=False, manga_mode_active=False,
unwanted_keywords=None, unwanted_keywords=None,
manga_filename_style=STYLE_POST_TITLE, manga_filename_style='post_title',
char_filter_scope=CHAR_SCOPE_FILES, char_filter_scope='files',
remove_from_filename_words_list=None, remove_from_filename_words_list=None,
manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, manga_date_prefix='',
allow_multipart_download=True, allow_multipart_download=True,
selected_cookie_file=None, selected_cookie_file=None,
override_output_dir=None, override_output_dir=None,
@@ -1602,6 +1635,10 @@ class DownloadThread(QThread):
creator_download_folder_ignore_words=None, creator_download_folder_ignore_words=None,
use_date_prefix_for_subfolder=False, use_date_prefix_for_subfolder=False,
keep_in_post_duplicates=False, keep_in_post_duplicates=False,
keep_duplicates_mode='hash',
keep_duplicates_limit=0,
downloaded_hash_counts=None,
downloaded_hash_counts_lock=None,
cookie_text="", cookie_text="",
session_file_path=None, session_file_path=None,
session_lock=None, session_lock=None,
@@ -1609,7 +1646,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): # Add processed_post_ids here 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
@@ -1622,7 +1660,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
@@ -1660,6 +1697,10 @@ class DownloadThread(QThread):
self.creator_download_folder_ignore_words = creator_download_folder_ignore_words self.creator_download_folder_ignore_words = creator_download_folder_ignore_words
self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder self.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
self.keep_in_post_duplicates = keep_in_post_duplicates self.keep_in_post_duplicates = keep_in_post_duplicates
self.keep_duplicates_mode = keep_duplicates_mode
self.keep_duplicates_limit = keep_duplicates_limit
self.downloaded_hash_counts = downloaded_hash_counts
self.downloaded_hash_counts_lock = downloaded_hash_counts_lock
self.manga_global_file_counter_ref = manga_global_file_counter_ref self.manga_global_file_counter_ref = manga_global_file_counter_ref
self.session_file_path = session_file_path self.session_file_path = session_file_path
self.session_lock = session_lock self.session_lock = session_lock
@@ -1668,7 +1709,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 [] # Add this line 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).")
@@ -1681,7 +1723,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
@@ -1700,6 +1744,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,
@@ -1713,105 +1758,105 @@ 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( # --- START OF FIX: Explicitly build the arguments dictionary ---
post_data=individual_post_data, # This robustly maps all thread attributes to the correct worker parameters.
download_root=self.output_dir, worker_args = {
known_names=self.known_names, 'post_data': individual_post_data,
filter_character_list=self.filter_character_list_objects_initial, 'emitter': worker_signals_obj,
dynamic_character_filter_holder=self.dynamic_filter_holder, 'download_root': self.output_dir,
unwanted_keywords=self.unwanted_keywords, 'known_names': self.known_names,
filter_mode=self.filter_mode, 'filter_character_list': self.filter_character_list_objects_initial,
skip_zip=self.skip_zip, skip_rar=self.skip_rar, 'dynamic_character_filter_holder': self.dynamic_filter_holder,
use_subfolders=self.use_subfolders, use_post_subfolders=self.use_post_subfolders, 'target_post_id_from_initial_url': self.initial_target_post_id,
target_post_id_from_initial_url=self.initial_target_post_id, 'num_file_threads': self.num_file_threads_for_worker,
custom_folder_name=self.custom_folder_name, 'processed_post_ids': list(self.processed_post_ids_set),
compress_images=self.compress_images, download_thumbnails=self.download_thumbnails, 'unwanted_keywords': self.unwanted_keywords,
service=self.service, user_id=self.user_id, 'filter_mode': self.filter_mode,
api_url_input=self.api_url_input, 'skip_zip': self.skip_zip,
pause_event=self.pause_event, 'use_subfolders': self.use_subfolders,
cancellation_event=self.cancellation_event, 'use_post_subfolders': self.use_post_subfolders,
emitter=worker_signals_obj, 'custom_folder_name': self.custom_folder_name,
downloaded_files=self.downloaded_files, 'compress_images': self.compress_images,
downloaded_file_hashes=self.downloaded_file_hashes, 'download_thumbnails': self.download_thumbnails,
downloaded_files_lock=self.downloaded_files_lock, 'service': self.service,
downloaded_file_hashes_lock=self.downloaded_file_hashes_lock, 'user_id': self.user_id,
skip_words_list=self.skip_words_list, 'api_url_input': self.api_url_input,
skip_words_scope=self.skip_words_scope, 'pause_event': self.pause_event,
show_external_links=self.show_external_links, 'cancellation_event': self.cancellation_event,
extract_links_only=self.extract_links_only, 'downloaded_files': self.downloaded_files,
num_file_threads=self.num_file_threads_for_worker, 'downloaded_file_hashes': self.downloaded_file_hashes,
skip_current_file_flag=self.skip_current_file_flag, 'downloaded_files_lock': self.downloaded_files_lock,
manga_mode_active=self.manga_mode_active, 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
manga_filename_style=self.manga_filename_style, 'skip_words_list': self.skip_words_list,
manga_date_prefix=self.manga_date_prefix, 'skip_words_scope': self.skip_words_scope,
char_filter_scope=self.char_filter_scope, 'show_external_links': self.show_external_links,
remove_from_filename_words_list=self.remove_from_filename_words_list, 'extract_links_only': self.extract_links_only,
allow_multipart_download=self.allow_multipart_download, 'skip_current_file_flag': self.skip_current_file_flag,
selected_cookie_file=self.selected_cookie_file, 'manga_mode_active': self.manga_mode_active,
app_base_dir=self.app_base_dir, 'manga_filename_style': self.manga_filename_style,
cookie_text=self.cookie_text, 'char_filter_scope': self.char_filter_scope,
override_output_dir=self.override_output_dir, 'remove_from_filename_words_list': self.remove_from_filename_words_list,
manga_global_file_counter_ref=self.manga_global_file_counter_ref, 'allow_multipart_download': self.allow_multipart_download,
use_cookie=self.use_cookie, 'cookie_text': self.cookie_text,
manga_date_file_counter_ref=self.manga_date_file_counter_ref, 'use_cookie': self.use_cookie,
use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder, 'override_output_dir': self.override_output_dir,
keep_in_post_duplicates=self.keep_in_post_duplicates, 'selected_cookie_file': self.selected_cookie_file,
creator_download_folder_ignore_words=self.creator_download_folder_ignore_words, 'app_base_dir': self.app_base_dir,
session_file_path=self.session_file_path, 'manga_date_prefix': self.manga_date_prefix,
session_lock=self.session_lock, 'manga_date_file_counter_ref': self.manga_date_file_counter_ref,
text_only_scope=self.text_only_scope, 'scan_content_for_images': self.scan_content_for_images,
text_export_format=self.text_export_format, 'creator_download_folder_ignore_words': self.creator_download_folder_ignore_words,
single_pdf_mode=self.single_pdf_mode, 'manga_global_file_counter_ref': self.manga_global_file_counter_ref,
project_root_dir=self.project_root_dir 'use_date_prefix_for_subfolder': self.use_date_prefix_for_subfolder,
) 'keep_in_post_duplicates': self.keep_in_post_duplicates,
try: 'keep_duplicates_mode': self.keep_duplicates_mode,
(dl_count, skip_count, kept_originals_this_post, 'keep_duplicates_limit': self.keep_duplicates_limit,
retryable_failures, permanent_failures, 'downloaded_hash_counts': self.downloaded_hash_counts,
history_data, temp_filepath) = post_processing_worker.process() 'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'session_file_path': self.session_file_path,
'session_lock': self.session_lock,
'text_only_scope': self.text_only_scope,
'text_export_format': self.text_export_format,
'single_pdf_mode': self.single_pdf_mode,
'project_root_dir': self.project_root_dir,
}
# --- END OF FIX ---
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.")
@@ -1820,7 +1865,6 @@ class DownloadThread(QThread):
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)
@@ -1831,14 +1875,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

@@ -5,9 +5,6 @@ import sys
# --- PyQt5 Imports --- # --- 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 +19,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 +31,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

@@ -9,9 +9,7 @@ from PyQt5.QtWidgets import (
) )
# --- Local Application Imports --- # --- 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
@@ -42,21 +40,15 @@ class DownloadExtractedLinksDialog(QDialog):
if not app_icon.isNull(): if not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically based on the parent window's size # --- START OF FIX ---
if parent: # Get the user-defined scale factor from the parent application.
parent_width = parent.width() scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
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 # Define base dimensions and apply the correct scale factor.
scaled_min_w = int(base_min_w * scale_factor) base_width, base_height = 600, 450
scaled_min_h = int(base_min_h * scale_factor) self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self.setMinimumSize(scaled_min_w, scaled_min_h) # --- END OF FIX ---
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 --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()

View File

@@ -42,13 +42,11 @@ 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 scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 1080.0 base_width, base_height = 550, 400
base_min_w, base_min_h = 500, 300 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
scaled_min_w = int(base_min_w * scale_factor) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()

View File

@@ -20,15 +20,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 +45,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 +59,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 +111,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 +130,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 +140,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

@@ -0,0 +1,122 @@
# KeepDuplicatesDialog.py
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
class KeepDuplicatesDialog(QDialog):
"""A dialog to choose the duplicate handling method, with a limit option."""
def __init__(self, current_mode, current_limit, parent=None):
super().__init__(parent)
self.parent_app = parent
self.selected_mode = current_mode
self.limit = current_limit
self._init_ui()
self._retranslate_ui()
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self)
# Set the initial state based on current settings
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
else:
self.radio_skip_by_hash.setChecked(True)
self.limit_input.setEnabled(False)
def _init_ui(self):
"""Initializes the UI components."""
main_layout = QVBoxLayout(self)
info_label = QLabel()
info_label.setWordWrap(True)
main_layout.addWidget(info_label)
options_group = QGroupBox()
options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self)
# --- Skip by Hash Option ---
self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash)
# --- Keep Everything Option with Limit Input ---
keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything)
keep_everything_layout.addWidget(self.radio_keep_everything)
keep_everything_layout.addStretch(1)
self.limit_label = QLabel()
self.limit_input = QLineEdit()
self.limit_input.setValidator(QIntValidator(0, 99))
self.limit_input.setFixedWidth(50)
keep_everything_layout.addWidget(self.limit_label)
keep_everything_layout.addWidget(self.limit_input)
options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group)
# --- OK and Cancel buttons ---
button_layout = QHBoxLayout()
self.ok_button = QPushButton()
self.cancel_button = QPushButton()
button_layout.addStretch(1)
button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
# --- Connections ---
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

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.
""" """
@@ -40,11 +39,10 @@ class KnownNamesFilterDialog(QDialog):
# Set window size dynamically # Set window size dynamically
screen_geometry = QApplication.primaryScreen().availableGeometry() screen_geometry = QApplication.primaryScreen().availableGeometry()
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
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))
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,14 +1,35 @@
# src/ui/dialogs/SupportDialog.py # src/ui/dialogs/SupportDialog.py
from PyQt5.QtWidgets import ( # --- Standard Library Imports ---
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox import sys
) import os
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
# Assuming execution from project root, so we can import from utils # --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox, QGridLayout
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QPixmap
# --- Local Application Imports ---
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
# --- Helper function for robust asset loading ---
def get_asset_path(filename):
"""
Gets the absolute path to a file in the assets folder,
handling both development and frozen (PyInstaller) environments.
"""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
base_path = sys._MEIPASS
else:
# Running in a normal Python environment from src/ui/dialogs/
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base_path, 'assets', filename)
class SupportDialog(QDialog): class SupportDialog(QDialog):
""" """
A dialog to show support and donation options. A dialog to show support and donation options.
@@ -17,11 +38,16 @@ class SupportDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.parent_app = parent self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer") self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(400) self.setMinimumWidth(450)
self._init_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
# Main layout # Main layout
layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
layout.setSpacing(15) main_layout.setSpacing(15)
# Title Label # Title Label
title_label = QLabel("Thank You for Your Support!") title_label = QLabel("Thank You for Your Support!")
@@ -30,7 +56,7 @@ class SupportDialog(QDialog):
font.setBold(True) font.setBold(True)
title_label.setFont(font) title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
layout.addWidget(title_label) main_layout.addWidget(title_label)
# Informational Text # Informational Text
info_label = QLabel( info_label = QLabel(
@@ -39,50 +65,86 @@ class SupportDialog(QDialog):
) )
info_label.setWordWrap(True) info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter) info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label) main_layout.addWidget(info_label)
# Separator # Separator
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.HLine) line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken) line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line) main_layout.addWidget(line)
# Donation Options # --- Donation Options Layout (using a grid for icons and text) ---
options_layout = QVBoxLayout() options_layout = QGridLayout()
options_layout.setSpacing(10) options_layout.setSpacing(18)
options_layout.setColumnStretch(0, 1) # Add stretch to center the content horizontally
options_layout.setColumnStretch(3, 1)
link_font = self.font()
link_font.setPointSize(12)
link_font.setBold(True)
scale = getattr(self.parent_app, 'scale_factor', 1.0)
icon_size = int(32 * scale)
# --- Ko-fi --- # --- Ko-fi ---
kofi_label = QLabel( kofi_icon_label = QLabel()
kofi_pixmap = QPixmap(get_asset_path("kofi.png"))
if not kofi_pixmap.isNull():
kofi_icon_label.setPixmap(kofi_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
kofi_text_label = QLabel(
'<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">' '<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">'
'☕ Buy me a Ko-fi' '☕ Buy me a Ko-fi'
'</a>' '</a>'
) )
kofi_label.setOpenExternalLinks(True) kofi_text_label.setOpenExternalLinks(True)
kofi_label.setAlignment(Qt.AlignCenter) kofi_text_label.setFont(link_font)
font.setPointSize(12)
kofi_label.setFont(font) options_layout.addWidget(kofi_icon_label, 0, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(kofi_label) options_layout.addWidget(kofi_text_label, 0, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- GitHub Sponsors --- # --- GitHub Sponsors ---
github_label = QLabel( github_icon_label = QLabel()
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #C9D1D9; text-decoration: none;">' github_pixmap = QPixmap(get_asset_path("github_sponsors.png"))
if not github_pixmap.isNull():
github_icon_label.setPixmap(github_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
github_text_label = QLabel(
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #EA4AAA; text-decoration: none;">'
'💜 Sponsor on GitHub' '💜 Sponsor on GitHub'
'</a>' '</a>'
) )
github_label.setOpenExternalLinks(True) github_text_label.setOpenExternalLinks(True)
github_label.setAlignment(Qt.AlignCenter) github_text_label.setFont(link_font)
github_label.setFont(font)
options_layout.addWidget(github_label)
layout.addLayout(options_layout) options_layout.addWidget(github_icon_label, 1, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(github_text_label, 1, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- Buy Me a Coffee (New) ---
bmac_icon_label = QLabel()
bmac_pixmap = QPixmap(get_asset_path("bmac.png"))
if not bmac_pixmap.isNull():
bmac_icon_label.setPixmap(bmac_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
bmac_text_label = QLabel(
'<a href="https://buymeacoffee.com/yuvi9587" style="color: #FFDD00; text-decoration: none;">'
'🍺 Buy Me a Coffee'
'</a>'
)
bmac_text_label.setOpenExternalLinks(True)
bmac_text_label.setFont(link_font)
options_layout.addWidget(bmac_icon_label, 2, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(bmac_text_label, 2, 2, Qt.AlignLeft | Qt.AlignVCenter)
main_layout.addLayout(options_layout)
# Close Button # Close Button
self.button_box = QDialogButtonBox(QDialogButtonBox.Close) self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
self.button_box.rejected.connect(self.reject) self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box) main_layout.addWidget(self.button_box)
self.setLayout(layout) self.setLayout(main_layout)
self._apply_theme()
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""

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)
@@ -559,11 +552,13 @@ def get_dark_theme(scale=1):
border: 1px solid #6A6A6A; border: 1px solid #6A6A6A;
padding: {tooltip_padding}px; padding: {tooltip_padding}px;
border-radius: 3px; border-radius: 3px;
font-size: {font_size}pt;
}} }}
QSplitter::handle {{ background-color: #5A5A5A; }} QSplitter::handle {{ background-color: #5A5A5A; }}
QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }} QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }}
QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }} QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }}
""" """
def apply_theme_to_app(main_app, theme_name, initial_load=False): def apply_theme_to_app(main_app, theme_name, initial_load=False):
""" """
Applies the selected theme and scaling to the main application window. Applies the selected theme and scaling to the main application window.