mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
29 Commits
9e58a9d574
...
v6.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33133eb275 | ||
|
|
3935cbeea4 | ||
|
|
8ba2a572fa | ||
|
|
8db40f03b6 | ||
|
|
742fe7685c | ||
|
|
e085d9a134 | ||
|
|
1cd03731c0 | ||
|
|
0bc8d7c692 | ||
|
|
3a9009e76e | ||
|
|
9a28e922b4 | ||
|
|
923a0ff61e | ||
|
|
e891a2a845 | ||
|
|
778b0219e2 | ||
|
|
3fc08d9ea7 | ||
|
|
af6a6add57 | ||
|
|
7737d32ef9 | ||
|
|
c08cbb6490 | ||
|
|
92a2e91624 | ||
|
|
11ea511a9d | ||
|
|
8abdb49ed8 | ||
|
|
0873dd1ce0 | ||
|
|
df5fbc1f73 | ||
|
|
5510f7f0c6 | ||
|
|
2f0593c450 | ||
|
|
e67adb6bdc | ||
|
|
d39081088c | ||
|
|
f303b8b020 | ||
|
|
539e76aa9e | ||
|
|
574d0d66b4 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1 +1,3 @@
|
||||
github: [Yuvi9587]
|
||||
ko_fi: yuvi427183
|
||||
buy_me_a_coffee: yuvi9587
|
||||
BIN
Read/bmac.gif
Normal file
BIN
Read/bmac.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 434 KiB |
@@ -41,6 +41,7 @@ Built with PyQt5, this tool is designed for users who want deep filtering capabi
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
@@ -208,4 +209,9 @@ This project is under the Custom Licence
|
||||
</a>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ THEME_KEY = "currentThemeV2"
|
||||
SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1"
|
||||
LANGUAGE_KEY = "currentLanguageV1"
|
||||
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
|
||||
RESOLUTION_KEY = "window_resolution"
|
||||
UI_SCALE_KEY = "ui_scale_factor"
|
||||
|
||||
# --- UI Constants and Identifiers ---
|
||||
HTML_PREFIX = "<!HTML!>"
|
||||
@@ -70,7 +72,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
|
||||
|
||||
# --- File Type 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'
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
@@ -111,3 +113,7 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
|
||||
"fri", "friday", "sat", "saturday", "sun", "sunday"
|
||||
# add more according to need
|
||||
}
|
||||
|
||||
# --- Duplicate Handling Modes ---
|
||||
DUPLICATE_HANDLING_HASH = "hash"
|
||||
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
|
||||
@@ -9,7 +9,7 @@ import uuid
|
||||
import http
|
||||
import html
|
||||
import json
|
||||
from collections import deque
|
||||
from collections import deque, defaultdict
|
||||
import hashlib
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future
|
||||
from io import BytesIO
|
||||
@@ -71,105 +71,113 @@ class PostProcessorSignals (QObject ):
|
||||
worker_finished_signal = pyqtSignal(tuple)
|
||||
|
||||
class PostProcessorWorker:
|
||||
def __init__ (self ,post_data ,download_root ,known_names ,
|
||||
filter_character_list ,emitter ,
|
||||
unwanted_keywords ,filter_mode ,skip_zip ,skip_rar ,
|
||||
use_subfolders ,use_post_subfolders ,target_post_id_from_initial_url ,custom_folder_name ,
|
||||
compress_images ,download_thumbnails ,service ,user_id ,pause_event ,
|
||||
api_url_input ,cancellation_event ,
|
||||
downloaded_files ,downloaded_file_hashes ,downloaded_files_lock ,downloaded_file_hashes_lock ,
|
||||
dynamic_character_filter_holder =None ,skip_words_list =None ,
|
||||
skip_words_scope =SKIP_SCOPE_FILES ,
|
||||
show_external_links =False ,
|
||||
extract_links_only =False ,
|
||||
num_file_threads =4 ,skip_current_file_flag =None ,
|
||||
manga_mode_active =False ,
|
||||
manga_filename_style =STYLE_POST_TITLE ,
|
||||
char_filter_scope =CHAR_SCOPE_FILES ,
|
||||
remove_from_filename_words_list =None ,
|
||||
allow_multipart_download =True ,
|
||||
cookie_text ="",
|
||||
use_cookie =False ,
|
||||
override_output_dir =None ,
|
||||
selected_cookie_file =None ,
|
||||
app_base_dir =None ,
|
||||
manga_date_prefix =MANGA_DATE_PREFIX_DEFAULT ,
|
||||
manga_date_file_counter_ref =None ,
|
||||
scan_content_for_images =False ,
|
||||
creator_download_folder_ignore_words =None ,
|
||||
manga_global_file_counter_ref =None ,
|
||||
use_date_prefix_for_subfolder=False,
|
||||
keep_in_post_duplicates=False,
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
text_only_scope=None,
|
||||
text_export_format='txt',
|
||||
single_pdf_mode=False,
|
||||
project_root_dir=None,
|
||||
processed_post_ids=None
|
||||
):
|
||||
self .post =post_data
|
||||
self .download_root =download_root
|
||||
self .known_names =known_names
|
||||
self .filter_character_list_objects_initial =filter_character_list if filter_character_list else []
|
||||
self .dynamic_filter_holder =dynamic_character_filter_holder
|
||||
self .unwanted_keywords =unwanted_keywords if unwanted_keywords is not None else set ()
|
||||
self .filter_mode =filter_mode
|
||||
self .skip_zip =skip_zip
|
||||
self .skip_rar =skip_rar
|
||||
self .use_subfolders =use_subfolders
|
||||
self .use_post_subfolders =use_post_subfolders
|
||||
self .target_post_id_from_initial_url =target_post_id_from_initial_url
|
||||
self .custom_folder_name =custom_folder_name
|
||||
self .compress_images =compress_images
|
||||
self .download_thumbnails =download_thumbnails
|
||||
self .service =service
|
||||
self .user_id =user_id
|
||||
self .api_url_input =api_url_input
|
||||
self .cancellation_event =cancellation_event
|
||||
self .pause_event =pause_event
|
||||
self .emitter =emitter
|
||||
if not self .emitter :
|
||||
raise ValueError ("PostProcessorWorker requires an emitter (signals object or queue).")
|
||||
self .skip_current_file_flag =skip_current_file_flag
|
||||
self .downloaded_files =downloaded_files if downloaded_files is not None else set ()
|
||||
self .downloaded_file_hashes =downloaded_file_hashes if downloaded_file_hashes is not None else set ()
|
||||
self .downloaded_files_lock =downloaded_files_lock if downloaded_files_lock is not None else threading .Lock ()
|
||||
self .downloaded_file_hashes_lock =downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading .Lock ()
|
||||
self .skip_words_list =skip_words_list if skip_words_list is not None else []
|
||||
self .skip_words_scope =skip_words_scope
|
||||
self .show_external_links =show_external_links
|
||||
self .extract_links_only =extract_links_only
|
||||
self .num_file_threads =num_file_threads
|
||||
self .manga_mode_active =manga_mode_active
|
||||
self .manga_filename_style =manga_filename_style
|
||||
self .char_filter_scope =char_filter_scope
|
||||
self .remove_from_filename_words_list =remove_from_filename_words_list if remove_from_filename_words_list is not None else []
|
||||
self .allow_multipart_download =allow_multipart_download
|
||||
self .manga_date_file_counter_ref =manga_date_file_counter_ref
|
||||
self .selected_cookie_file =selected_cookie_file
|
||||
self .app_base_dir =app_base_dir
|
||||
self .cookie_text =cookie_text
|
||||
self .manga_date_prefix =manga_date_prefix
|
||||
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
|
||||
|
||||
def __init__(self, post_data, download_root, known_names,
|
||||
filter_character_list, emitter,
|
||||
unwanted_keywords, filter_mode, skip_zip,
|
||||
use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
|
||||
compress_images, download_thumbnails, service, user_id, pause_event,
|
||||
api_url_input, cancellation_event,
|
||||
downloaded_files, downloaded_file_hashes, downloaded_files_lock, downloaded_file_hashes_lock,
|
||||
dynamic_character_filter_holder=None, skip_words_list=None,
|
||||
skip_words_scope=SKIP_SCOPE_FILES,
|
||||
show_external_links=False,
|
||||
extract_links_only=False,
|
||||
num_file_threads=4, skip_current_file_flag=None,
|
||||
manga_mode_active=False,
|
||||
manga_filename_style=STYLE_POST_TITLE,
|
||||
char_filter_scope=CHAR_SCOPE_FILES,
|
||||
remove_from_filename_words_list=None,
|
||||
allow_multipart_download=True,
|
||||
cookie_text="",
|
||||
use_cookie=False,
|
||||
override_output_dir=None,
|
||||
selected_cookie_file=None,
|
||||
app_base_dir=None,
|
||||
manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT,
|
||||
manga_date_file_counter_ref=None,
|
||||
scan_content_for_images=False,
|
||||
creator_download_folder_ignore_words=None,
|
||||
manga_global_file_counter_ref=None,
|
||||
use_date_prefix_for_subfolder=False,
|
||||
keep_in_post_duplicates=False,
|
||||
keep_duplicates_mode=DUPLICATE_HANDLING_HASH,
|
||||
keep_duplicates_limit=0,
|
||||
downloaded_hash_counts=None,
|
||||
downloaded_hash_counts_lock=None,
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
text_only_scope=None,
|
||||
text_export_format='txt',
|
||||
single_pdf_mode=False,
|
||||
project_root_dir=None,
|
||||
processed_post_ids=None
|
||||
):
|
||||
self.post = post_data
|
||||
self.download_root = download_root
|
||||
self.known_names = known_names
|
||||
self.filter_character_list_objects_initial = filter_character_list if filter_character_list else []
|
||||
self.dynamic_filter_holder = dynamic_character_filter_holder
|
||||
self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
|
||||
self.filter_mode = filter_mode
|
||||
self.skip_zip = skip_zip
|
||||
self.use_subfolders = use_subfolders
|
||||
self.use_post_subfolders = use_post_subfolders
|
||||
self.target_post_id_from_initial_url = target_post_id_from_initial_url
|
||||
self.custom_folder_name = custom_folder_name
|
||||
self.compress_images = compress_images
|
||||
self.download_thumbnails = download_thumbnails
|
||||
self.service = service
|
||||
self.user_id = user_id
|
||||
self.api_url_input = api_url_input
|
||||
self.cancellation_event = cancellation_event
|
||||
self.pause_event = pause_event
|
||||
self.emitter = emitter
|
||||
if not self.emitter:
|
||||
raise ValueError("PostProcessorWorker requires an emitter (signals object or queue).")
|
||||
self.skip_current_file_flag = skip_current_file_flag
|
||||
self.downloaded_files = downloaded_files if downloaded_files is not None else set()
|
||||
self.downloaded_file_hashes = downloaded_file_hashes if downloaded_file_hashes is not None else set()
|
||||
self.downloaded_files_lock = downloaded_files_lock if downloaded_files_lock is not None else threading.Lock()
|
||||
self.downloaded_file_hashes_lock = downloaded_file_hashes_lock if downloaded_file_hashes_lock is not None else threading.Lock()
|
||||
self.skip_words_list = skip_words_list if skip_words_list is not None else []
|
||||
self.skip_words_scope = skip_words_scope
|
||||
self.show_external_links = show_external_links
|
||||
self.extract_links_only = extract_links_only
|
||||
self.num_file_threads = num_file_threads
|
||||
self.manga_mode_active = manga_mode_active
|
||||
self.manga_filename_style = manga_filename_style
|
||||
self.char_filter_scope = char_filter_scope
|
||||
self.remove_from_filename_words_list = remove_from_filename_words_list if remove_from_filename_words_list is not None else []
|
||||
self.allow_multipart_download = allow_multipart_download
|
||||
self.manga_date_file_counter_ref = manga_date_file_counter_ref
|
||||
self.selected_cookie_file = selected_cookie_file
|
||||
self.app_base_dir = app_base_dir
|
||||
self.cookie_text = cookie_text
|
||||
self.manga_date_prefix = manga_date_prefix
|
||||
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.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_lock = session_lock
|
||||
self.text_only_scope = text_only_scope
|
||||
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.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 ):
|
||||
"""Helper to emit signal either directly or via queue."""
|
||||
if isinstance (self .emitter ,queue .Queue ):
|
||||
@@ -179,6 +187,7 @@ class PostProcessorWorker:
|
||||
signal_attr .emit (*payload_args )
|
||||
else :
|
||||
print (f"(Worker Log - Unrecognized Emitter for {signal_type_str }): {payload_args [0 ]if payload_args else ''}")
|
||||
|
||||
def logger (self ,message ):
|
||||
self ._emit_signal ('progress',message )
|
||||
def check_cancel (self ):
|
||||
@@ -384,13 +393,9 @@ class PostProcessorWorker:
|
||||
if not is_audio_type:
|
||||
self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).")
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
if self.skip_zip and is_zip(api_original_filename):
|
||||
self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).")
|
||||
if (self.skip_zip) and is_archive(api_original_filename):
|
||||
self.logger(f" -> Pref Skip: '{api_original_filename}' (Archive).")
|
||||
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:
|
||||
os.makedirs(target_folder_path, exist_ok=True)
|
||||
except OSError as e:
|
||||
@@ -408,6 +413,7 @@ class PostProcessorWorker:
|
||||
total_size_bytes = 0
|
||||
download_successful_flag = False
|
||||
last_exception_for_retry_later = None
|
||||
is_permanent_error = False
|
||||
data_to_write_io = None
|
||||
|
||||
response_for_this_attempt = None
|
||||
@@ -512,12 +518,14 @@ class PostProcessorWorker:
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger(f" ❌ Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
|
||||
last_exception_for_retry_later = e
|
||||
is_permanent_error = True
|
||||
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.")
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
|
||||
last_exception_for_retry_later = e
|
||||
is_permanent_error = True
|
||||
break
|
||||
finally:
|
||||
if response_for_this_attempt:
|
||||
@@ -544,7 +552,6 @@ class PostProcessorWorker:
|
||||
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):
|
||||
self.logger(f" ⚠️ Download process interrupted for {api_original_filename}.")
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
try:
|
||||
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}'"):
|
||||
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 ###
|
||||
with self.downloaded_file_hashes_lock:
|
||||
if calculated_file_hash in self.downloaded_file_hashes:
|
||||
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.
|
||||
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
|
||||
should_skip = False
|
||||
with self.downloaded_hash_counts_lock:
|
||||
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
|
||||
|
||||
# Default to not skipping
|
||||
decision_to_skip = False
|
||||
|
||||
# Apply logic based on mode
|
||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||
if current_count >= 1:
|
||||
decision_to_skip = True
|
||||
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
|
||||
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
|
||||
counter = 1
|
||||
@@ -603,8 +628,6 @@ class PostProcessorWorker:
|
||||
|
||||
with self.downloaded_file_hashes_lock:
|
||||
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
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
os.remove(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:
|
||||
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):
|
||||
try:
|
||||
@@ -656,22 +676,25 @@ class PostProcessorWorker:
|
||||
if data_to_write_io and hasattr(data_to_write_io, 'close'):
|
||||
data_to_write_io.close()
|
||||
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.")
|
||||
retry_later_details = {
|
||||
details_for_failure = {
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
|
||||
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post
|
||||
}
|
||||
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):
|
||||
# Default "empty" result tuple. It will be updated before any return path.
|
||||
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
try:
|
||||
if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"):
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
return result_tuple # Return for the direct caller
|
||||
return result_tuple
|
||||
if self.check_cancel():
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
return result_tuple
|
||||
@@ -701,7 +724,8 @@ class PostProcessorWorker:
|
||||
effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words)
|
||||
|
||||
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)
|
||||
|
||||
post_is_candidate_by_title_char_match = False
|
||||
@@ -1236,6 +1260,21 @@ class PostProcessorWorker:
|
||||
else:
|
||||
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:
|
||||
self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...")
|
||||
parsed_input_url = urlparse(self.api_url_input)
|
||||
@@ -1528,9 +1567,7 @@ class PostProcessorWorker:
|
||||
'service': self.service, 'user_id': self.user_id,
|
||||
}
|
||||
|
||||
if self.check_cancel():
|
||||
self.logger(f" Post {post_id} processing interrupted/cancelled.")
|
||||
else:
|
||||
if not self.check_cancel():
|
||||
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:
|
||||
@@ -1542,18 +1579,14 @@ class PostProcessorWorker:
|
||||
except OSError as 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,
|
||||
kept_original_filenames_for_log, retryable_failures_this_post,
|
||||
permanent_failures_this_post, history_data_for_this_post,
|
||||
None)
|
||||
|
||||
finally:
|
||||
# This block is GUARANTEED to execute, sending the signal for multi-threaded mode.
|
||||
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
|
||||
|
||||
class DownloadThread(QThread):
|
||||
@@ -1573,12 +1606,12 @@ class DownloadThread(QThread):
|
||||
def __init__(self, api_url_input, output_dir, known_names_copy,
|
||||
cancellation_event,
|
||||
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,
|
||||
download_thumbnails=False, service=None, user_id=None,
|
||||
downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None,
|
||||
skip_words_list=None,
|
||||
skip_words_scope=SKIP_SCOPE_FILES,
|
||||
skip_words_scope='files',
|
||||
show_external_links=False,
|
||||
extract_links_only=False,
|
||||
num_file_threads_for_worker=1,
|
||||
@@ -1587,10 +1620,10 @@ class DownloadThread(QThread):
|
||||
target_post_id_from_initial_url=None,
|
||||
manga_mode_active=False,
|
||||
unwanted_keywords=None,
|
||||
manga_filename_style=STYLE_POST_TITLE,
|
||||
char_filter_scope=CHAR_SCOPE_FILES,
|
||||
manga_filename_style='post_title',
|
||||
char_filter_scope='files',
|
||||
remove_from_filename_words_list=None,
|
||||
manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT,
|
||||
manga_date_prefix='',
|
||||
allow_multipart_download=True,
|
||||
selected_cookie_file=None,
|
||||
override_output_dir=None,
|
||||
@@ -1602,6 +1635,10 @@ class DownloadThread(QThread):
|
||||
creator_download_folder_ignore_words=None,
|
||||
use_date_prefix_for_subfolder=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="",
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
@@ -1609,7 +1646,8 @@ class DownloadThread(QThread):
|
||||
text_export_format='txt',
|
||||
single_pdf_mode=False,
|
||||
project_root_dir=None,
|
||||
processed_post_ids=None): # Add processed_post_ids here
|
||||
processed_post_ids=None,
|
||||
start_offset=0):
|
||||
super().__init__()
|
||||
self.api_url_input = api_url_input
|
||||
self.output_dir = output_dir
|
||||
@@ -1622,7 +1660,6 @@ class DownloadThread(QThread):
|
||||
self.dynamic_filter_holder = dynamic_character_filter_holder
|
||||
self.filter_mode = filter_mode
|
||||
self.skip_zip = skip_zip
|
||||
self.skip_rar = skip_rar
|
||||
self.use_subfolders = use_subfolders
|
||||
self.use_post_subfolders = use_post_subfolders
|
||||
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.use_date_prefix_for_subfolder = use_date_prefix_for_subfolder
|
||||
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.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
@@ -1668,7 +1709,8 @@ class DownloadThread(QThread):
|
||||
self.text_export_format = text_export_format
|
||||
self.single_pdf_mode = single_pdf_mode
|
||||
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:
|
||||
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
|
||||
@@ -1681,7 +1723,9 @@ class DownloadThread(QThread):
|
||||
|
||||
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_skipped_files = 0
|
||||
@@ -1700,6 +1744,7 @@ class DownloadThread(QThread):
|
||||
worker_signals_obj.worker_finished_signal.connect(lambda result: None)
|
||||
|
||||
self.logger(" Starting post fetch (single-threaded download process)...")
|
||||
|
||||
post_generator = download_from_api(
|
||||
self.api_url_input,
|
||||
logger=self.logger,
|
||||
@@ -1713,105 +1758,105 @@ class DownloadThread(QThread):
|
||||
selected_cookie_file=self.selected_cookie_file,
|
||||
app_base_dir=self.app_base_dir,
|
||||
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
|
||||
processed_post_ids=self.processed_post_ids_set
|
||||
)
|
||||
|
||||
for posts_batch_data in post_generator:
|
||||
if self.isInterruptionRequested():
|
||||
was_process_cancelled = True
|
||||
break
|
||||
|
||||
for individual_post_data in posts_batch_data:
|
||||
if self.isInterruptionRequested():
|
||||
was_process_cancelled = True
|
||||
break
|
||||
|
||||
post_processing_worker = PostProcessorWorker(
|
||||
post_data=individual_post_data,
|
||||
download_root=self.output_dir,
|
||||
known_names=self.known_names,
|
||||
filter_character_list=self.filter_character_list_objects_initial,
|
||||
dynamic_character_filter_holder=self.dynamic_filter_holder,
|
||||
unwanted_keywords=self.unwanted_keywords,
|
||||
filter_mode=self.filter_mode,
|
||||
skip_zip=self.skip_zip, skip_rar=self.skip_rar,
|
||||
use_subfolders=self.use_subfolders, use_post_subfolders=self.use_post_subfolders,
|
||||
target_post_id_from_initial_url=self.initial_target_post_id,
|
||||
custom_folder_name=self.custom_folder_name,
|
||||
compress_images=self.compress_images, download_thumbnails=self.download_thumbnails,
|
||||
service=self.service, user_id=self.user_id,
|
||||
api_url_input=self.api_url_input,
|
||||
pause_event=self.pause_event,
|
||||
cancellation_event=self.cancellation_event,
|
||||
emitter=worker_signals_obj,
|
||||
downloaded_files=self.downloaded_files,
|
||||
downloaded_file_hashes=self.downloaded_file_hashes,
|
||||
downloaded_files_lock=self.downloaded_files_lock,
|
||||
downloaded_file_hashes_lock=self.downloaded_file_hashes_lock,
|
||||
skip_words_list=self.skip_words_list,
|
||||
skip_words_scope=self.skip_words_scope,
|
||||
show_external_links=self.show_external_links,
|
||||
extract_links_only=self.extract_links_only,
|
||||
num_file_threads=self.num_file_threads_for_worker,
|
||||
skip_current_file_flag=self.skip_current_file_flag,
|
||||
manga_mode_active=self.manga_mode_active,
|
||||
manga_filename_style=self.manga_filename_style,
|
||||
manga_date_prefix=self.manga_date_prefix,
|
||||
char_filter_scope=self.char_filter_scope,
|
||||
remove_from_filename_words_list=self.remove_from_filename_words_list,
|
||||
allow_multipart_download=self.allow_multipart_download,
|
||||
selected_cookie_file=self.selected_cookie_file,
|
||||
app_base_dir=self.app_base_dir,
|
||||
cookie_text=self.cookie_text,
|
||||
override_output_dir=self.override_output_dir,
|
||||
manga_global_file_counter_ref=self.manga_global_file_counter_ref,
|
||||
use_cookie=self.use_cookie,
|
||||
manga_date_file_counter_ref=self.manga_date_file_counter_ref,
|
||||
use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder,
|
||||
keep_in_post_duplicates=self.keep_in_post_duplicates,
|
||||
creator_download_folder_ignore_words=self.creator_download_folder_ignore_words,
|
||||
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
|
||||
)
|
||||
try:
|
||||
(dl_count, skip_count, kept_originals_this_post,
|
||||
retryable_failures, permanent_failures,
|
||||
history_data, temp_filepath) = post_processing_worker.process()
|
||||
# --- START OF FIX: Explicitly build the arguments dictionary ---
|
||||
# This robustly maps all thread attributes to the correct worker parameters.
|
||||
worker_args = {
|
||||
'post_data': individual_post_data,
|
||||
'emitter': worker_signals_obj,
|
||||
'download_root': self.output_dir,
|
||||
'known_names': self.known_names,
|
||||
'filter_character_list': self.filter_character_list_objects_initial,
|
||||
'dynamic_character_filter_holder': self.dynamic_filter_holder,
|
||||
'target_post_id_from_initial_url': self.initial_target_post_id,
|
||||
'num_file_threads': self.num_file_threads_for_worker,
|
||||
'processed_post_ids': list(self.processed_post_ids_set),
|
||||
'unwanted_keywords': self.unwanted_keywords,
|
||||
'filter_mode': self.filter_mode,
|
||||
'skip_zip': self.skip_zip,
|
||||
'use_subfolders': self.use_subfolders,
|
||||
'use_post_subfolders': self.use_post_subfolders,
|
||||
'custom_folder_name': self.custom_folder_name,
|
||||
'compress_images': self.compress_images,
|
||||
'download_thumbnails': self.download_thumbnails,
|
||||
'service': self.service,
|
||||
'user_id': self.user_id,
|
||||
'api_url_input': self.api_url_input,
|
||||
'pause_event': self.pause_event,
|
||||
'cancellation_event': self.cancellation_event,
|
||||
'downloaded_files': self.downloaded_files,
|
||||
'downloaded_file_hashes': self.downloaded_file_hashes,
|
||||
'downloaded_files_lock': self.downloaded_files_lock,
|
||||
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
|
||||
'skip_words_list': self.skip_words_list,
|
||||
'skip_words_scope': self.skip_words_scope,
|
||||
'show_external_links': self.show_external_links,
|
||||
'extract_links_only': self.extract_links_only,
|
||||
'skip_current_file_flag': self.skip_current_file_flag,
|
||||
'manga_mode_active': self.manga_mode_active,
|
||||
'manga_filename_style': self.manga_filename_style,
|
||||
'char_filter_scope': self.char_filter_scope,
|
||||
'remove_from_filename_words_list': self.remove_from_filename_words_list,
|
||||
'allow_multipart_download': self.allow_multipart_download,
|
||||
'cookie_text': self.cookie_text,
|
||||
'use_cookie': self.use_cookie,
|
||||
'override_output_dir': self.override_output_dir,
|
||||
'selected_cookie_file': self.selected_cookie_file,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
'manga_date_prefix': self.manga_date_prefix,
|
||||
'manga_date_file_counter_ref': self.manga_date_file_counter_ref,
|
||||
'scan_content_for_images': self.scan_content_for_images,
|
||||
'creator_download_folder_ignore_words': self.creator_download_folder_ignore_words,
|
||||
'manga_global_file_counter_ref': self.manga_global_file_counter_ref,
|
||||
'use_date_prefix_for_subfolder': self.use_date_prefix_for_subfolder,
|
||||
'keep_in_post_duplicates': self.keep_in_post_duplicates,
|
||||
'keep_duplicates_mode': self.keep_duplicates_mode,
|
||||
'keep_duplicates_limit': self.keep_duplicates_limit,
|
||||
'downloaded_hash_counts': self.downloaded_hash_counts,
|
||||
'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
|
||||
grand_total_skipped_files += skip_count
|
||||
post_processing_worker = PostProcessorWorker(**worker_args)
|
||||
|
||||
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:
|
||||
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)
|
||||
(dl_count, skip_count, kept_originals_this_post,
|
||||
retryable_failures, permanent_failures,
|
||||
history_data, temp_filepath) = post_processing_worker.process()
|
||||
|
||||
if self.single_pdf_mode and temp_filepath:
|
||||
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}")
|
||||
grand_total_downloaded_files += dl_count
|
||||
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:
|
||||
break
|
||||
|
||||
if not was_process_cancelled and not self.isInterruptionRequested():
|
||||
self.logger("✅ All posts processed or end of content reached by DownloadThread.")
|
||||
|
||||
@@ -1820,7 +1865,6 @@ class DownloadThread(QThread):
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
try:
|
||||
# Disconnect signals
|
||||
if worker_signals_obj:
|
||||
worker_signals_obj.progress_signal.disconnect(self.progress_signal)
|
||||
worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal)
|
||||
@@ -1830,15 +1874,9 @@ class DownloadThread(QThread):
|
||||
worker_signals_obj.file_successfully_downloaded_signal.disconnect(self.file_successfully_downloaded_signal)
|
||||
except (TypeError, RuntimeError) as 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)
|
||||
|
||||
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):
|
||||
"""Custom exception for handling cancellations gracefully."""
|
||||
pass
|
||||
File diff suppressed because one or more lines are too long
@@ -5,9 +5,6 @@ import sys
|
||||
# --- PyQt5 Imports ---
|
||||
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
|
||||
|
||||
def get_app_icon_object():
|
||||
@@ -22,17 +19,11 @@ def get_app_icon_object():
|
||||
if _app_icon_cache and not _app_icon_cache.isNull():
|
||||
return _app_icon_cache
|
||||
|
||||
# Declare a single variable to hold the base directory path.
|
||||
app_base_dir = ""
|
||||
|
||||
# Determine the project's base directory, whether running from source or as a bundled app
|
||||
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)
|
||||
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__), '..', '..'))
|
||||
|
||||
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):
|
||||
_app_icon_cache = QIcon(icon_path)
|
||||
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'):
|
||||
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
|
||||
if os.path.exists(fallback_icon_path):
|
||||
|
||||
@@ -9,9 +9,7 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# This assumes the new project structure is in place.
|
||||
from ...i18n.translator import get_translation
|
||||
# get_app_icon_object is defined in the main window module in this refactoring plan.
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -42,21 +40,15 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
if not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically based on the parent window's size
|
||||
if parent:
|
||||
parent_width = parent.width()
|
||||
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
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.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))
|
||||
# Define base dimensions and apply the correct scale factor.
|
||||
base_width, base_height = 600, 450
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -186,4 +178,4 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
self,
|
||||
self._tr("no_selection_title", "No Selection"),
|
||||
self._tr("no_selection_message_links", "Please select at least one link to download.")
|
||||
)
|
||||
)
|
||||
@@ -140,12 +140,11 @@ class EmptyPopupDialog (QDialog ):
|
||||
|
||||
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
|
||||
super ().__init__ (parent )
|
||||
self .setMinimumSize (400 ,300 )
|
||||
screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
|
||||
scale_factor =screen_height /768.0
|
||||
self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
|
||||
self.parent_app = parent_app_ref
|
||||
|
||||
self .parent_app =parent_app_ref
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
self.setMinimumSize(int(400 * scale_factor), int(300 * scale_factor))
|
||||
self.current_scope_mode = self.SCOPE_CREATORS
|
||||
self .app_base_dir =app_base_dir
|
||||
|
||||
|
||||
@@ -42,13 +42,11 @@ class ErrorFilesDialog(QDialog):
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 1080.0
|
||||
base_min_w, base_min_h = 500, 300
|
||||
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)
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
base_width, base_height = 550, 400
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -232,4 +230,4 @@ class ErrorFilesDialog(QDialog):
|
||||
self,
|
||||
self._tr("error_files_export_error_title", "Export Error"),
|
||||
self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
|
||||
)
|
||||
)
|
||||
@@ -1,119 +1,139 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import json
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt, QStandardPaths
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# This assumes the new project structure is in place.
|
||||
from ...i18n.translator import get_translation
|
||||
from ...utils.resolution import get_dark_theme
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...config.constants import (
|
||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY
|
||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
|
||||
RESOLUTION_KEY, UI_SCALE_KEY
|
||||
)
|
||||
|
||||
|
||||
class FutureSettingsDialog(QDialog):
|
||||
"""
|
||||
A dialog for managing application-wide settings like theme, language,
|
||||
and saving the default download path.
|
||||
and display options, with an organized layout.
|
||||
"""
|
||||
def __init__(self, parent_app_ref, parent=None):
|
||||
"""
|
||||
Initializes the dialog.
|
||||
|
||||
Args:
|
||||
parent_app_ref (DownloaderApp): A reference to the main application window.
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent_app_ref
|
||||
self.setModal(True)
|
||||
|
||||
# --- Basic Window Setup ---
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 768.0
|
||||
base_min_w, base_min_h = 380, 250
|
||||
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||
scale_factor = screen_height / 800.0
|
||||
base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
self._apply_theme()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initializes all UI components and layouts for the dialog."""
|
||||
layout = QVBoxLayout(self)
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# --- Appearance Settings ---
|
||||
self.appearance_group_box = QGroupBox()
|
||||
appearance_layout = QVBoxLayout(self.appearance_group_box)
|
||||
# --- Group 1: Interface Settings ---
|
||||
self.interface_group_box = QGroupBox()
|
||||
interface_layout = QGridLayout(self.interface_group_box)
|
||||
|
||||
# Theme
|
||||
self.theme_label = QLabel()
|
||||
self.theme_toggle_button = QPushButton()
|
||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||
appearance_layout.addWidget(self.theme_toggle_button)
|
||||
layout.addWidget(self.appearance_group_box)
|
||||
interface_layout.addWidget(self.theme_label, 0, 0)
|
||||
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
|
||||
# --- Language Settings ---
|
||||
self.language_group_box = QGroupBox()
|
||||
language_group_layout = QVBoxLayout(self.language_group_box)
|
||||
self.language_selection_layout = QHBoxLayout()
|
||||
# UI Scale
|
||||
self.ui_scale_label = QLabel()
|
||||
self.ui_scale_combo_box = QComboBox()
|
||||
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
interface_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
|
||||
# Language
|
||||
self.language_label = QLabel()
|
||||
self.language_selection_layout.addWidget(self.language_label)
|
||||
self.language_combo_box = QComboBox()
|
||||
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
|
||||
self.language_selection_layout.addWidget(self.language_combo_box, 1)
|
||||
language_group_layout.addLayout(self.language_selection_layout)
|
||||
layout.addWidget(self.language_group_box)
|
||||
|
||||
# --- Download Settings ---
|
||||
self.download_settings_group_box = QGroupBox()
|
||||
download_settings_layout = QVBoxLayout(self.download_settings_group_box)
|
||||
interface_layout.addWidget(self.language_label, 2, 0)
|
||||
interface_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
|
||||
main_layout.addWidget(self.interface_group_box)
|
||||
|
||||
# --- Group 2: Download & Window Settings ---
|
||||
self.download_window_group_box = QGroupBox()
|
||||
download_window_layout = QGridLayout(self.download_window_group_box)
|
||||
|
||||
# Window Size (Resolution)
|
||||
self.window_size_label = QLabel()
|
||||
self.resolution_combo_box = QComboBox()
|
||||
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
download_window_layout.addWidget(self.window_size_label, 0, 0)
|
||||
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
|
||||
|
||||
# Default Path
|
||||
self.default_path_label = QLabel()
|
||||
self.save_path_button = QPushButton()
|
||||
self.save_path_button.clicked.connect(self._save_download_path)
|
||||
download_settings_layout.addWidget(self.save_path_button)
|
||||
layout.addWidget(self.download_settings_group_box)
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
layout.addStretch(1)
|
||||
main_layout.addWidget(self.download_window_group_box)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
|
||||
# --- OK Button ---
|
||||
self.ok_button = QPushButton()
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on the main application's current language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _retranslate_ui(self):
|
||||
"""Sets the text for all translatable UI elements."""
|
||||
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
|
||||
self.appearance_group_box.setTitle(self._tr("appearance_group_title", "Appearance"))
|
||||
self.language_group_box.setTitle(self._tr("language_group_title", "Language Settings"))
|
||||
self.download_settings_group_box.setTitle(self._tr("settings_download_group_title", "Download Settings"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
self._update_theme_toggle_button_text()
|
||||
self._populate_language_combo_box()
|
||||
|
||||
# Group Box Titles
|
||||
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
|
||||
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
|
||||
|
||||
# Interface Group Labels
|
||||
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
||||
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
|
||||
# Download & Window Group Labels
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
|
||||
# Buttons and Controls
|
||||
self._update_theme_toggle_button_text()
|
||||
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path"))
|
||||
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
# Populate dropdowns
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
|
||||
def _apply_theme(self):
|
||||
"""Applies the current theme from the parent application."""
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
@@ -121,53 +141,106 @@ class FutureSettingsDialog(QDialog):
|
||||
self.setStyleSheet("")
|
||||
|
||||
def _update_theme_toggle_button_text(self):
|
||||
"""Updates the theme button text and tooltip based on the current theme."""
|
||||
if self.parent_app.current_theme == "dark":
|
||||
self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode"))
|
||||
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_light", "Change the application appearance to light."))
|
||||
else:
|
||||
self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode"))
|
||||
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_dark", "Change the application appearance to dark."))
|
||||
|
||||
def _toggle_theme(self):
|
||||
"""Toggles the application theme and updates the UI."""
|
||||
new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
|
||||
self.parent_app.apply_theme(new_theme)
|
||||
self._retranslate_ui()
|
||||
self.parent_app.settings.setValue(THEME_KEY, new_theme)
|
||||
self.parent_app.settings.sync()
|
||||
self.parent_app.current_theme = new_theme
|
||||
self._apply_theme()
|
||||
if hasattr(self.parent_app, '_apply_theme_and_restart_prompt'):
|
||||
self.parent_app._apply_theme_and_restart_prompt()
|
||||
|
||||
def _populate_display_combo_boxes(self):
|
||||
self.resolution_combo_box.blockSignals(True)
|
||||
self.resolution_combo_box.clear()
|
||||
resolutions = [
|
||||
("Auto", self._tr("auto_resolution", "Auto (System Default)")),
|
||||
("1280x720", "1280 x 720"),
|
||||
("1600x900", "1600 x 900"),
|
||||
("1920x1080", "1920 x 1080 (Full HD)"),
|
||||
("2560x1440", "2560 x 1440 (2K)"),
|
||||
("3840x2160", "3840 x 2160 (4K)")
|
||||
]
|
||||
current_res = self.parent_app.settings.value(RESOLUTION_KEY, "Auto")
|
||||
for res_key, res_name in resolutions:
|
||||
self.resolution_combo_box.addItem(res_name, res_key)
|
||||
if current_res == res_key:
|
||||
self.resolution_combo_box.setCurrentIndex(self.resolution_combo_box.count() - 1)
|
||||
self.resolution_combo_box.blockSignals(False)
|
||||
|
||||
self.ui_scale_combo_box.blockSignals(True)
|
||||
self.ui_scale_combo_box.clear()
|
||||
scales = [
|
||||
(0.5, "50%"),
|
||||
(0.7, "70%"),
|
||||
(0.9, "90%"),
|
||||
(1.0, "100% (Default)"),
|
||||
(1.25, "125%"),
|
||||
(1.50, "150%"),
|
||||
(1.75, "175%"),
|
||||
(2.0, "200%")
|
||||
]
|
||||
|
||||
current_scale = float(self.parent_app.settings.value(UI_SCALE_KEY, 1.0))
|
||||
for scale_val, scale_name in scales:
|
||||
self.ui_scale_combo_box.addItem(scale_name, scale_val)
|
||||
if abs(current_scale - scale_val) < 0.01:
|
||||
self.ui_scale_combo_box.setCurrentIndex(self.ui_scale_combo_box.count() - 1)
|
||||
self.ui_scale_combo_box.blockSignals(False)
|
||||
|
||||
def _display_setting_changed(self):
|
||||
selected_res = self.resolution_combo_box.currentData()
|
||||
selected_scale = self.ui_scale_combo_box.currentData()
|
||||
|
||||
self.parent_app.settings.setValue(RESOLUTION_KEY, selected_res)
|
||||
self.parent_app.settings.setValue(UI_SCALE_KEY, selected_scale)
|
||||
self.parent_app.settings.sync()
|
||||
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Information)
|
||||
msg_box.setWindowTitle(self._tr("display_change_title", "Display Settings Changed"))
|
||||
msg_box.setText(self._tr("language_change_message", "A restart is required for these changes to take effect."))
|
||||
msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
|
||||
restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
|
||||
ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
|
||||
msg_box.setDefaultButton(ok_button)
|
||||
msg_box.exec_()
|
||||
|
||||
if msg_box.clickedButton() == restart_button:
|
||||
self.parent_app._request_restart_application()
|
||||
|
||||
def _populate_language_combo_box(self):
|
||||
"""Populates the language dropdown with available languages."""
|
||||
self.language_combo_box.blockSignals(True)
|
||||
self.language_combo_box.clear()
|
||||
languages = [
|
||||
("en","English"),
|
||||
("ja","日本語 (Japanese)"),
|
||||
("fr","Français (French)"),
|
||||
("de","Deutsch (German)"),
|
||||
("es","Español (Spanish)"),
|
||||
("pt","Português (Portuguese)"),
|
||||
("ru","Русский (Russian)"),
|
||||
("zh_CN","简体中文 (Simplified Chinese)"),
|
||||
("zh_TW","繁體中文 (Traditional Chinese)"),
|
||||
("ko","한국어 (Korean)")
|
||||
("en", "English"), ("ja", "日本語 (Japanese)"), ("fr", "Français (French)"),
|
||||
("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
|
||||
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
|
||||
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
|
||||
]
|
||||
current_lang = self.parent_app.current_selected_language
|
||||
for lang_code, lang_name in languages:
|
||||
self.language_combo_box.addItem(lang_name, lang_code)
|
||||
if self.parent_app.current_selected_language == lang_code:
|
||||
if current_lang == lang_code:
|
||||
self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1)
|
||||
self.language_combo_box.blockSignals(False)
|
||||
|
||||
def _language_selection_changed(self, index):
|
||||
"""Handles the user selecting a new language."""
|
||||
selected_lang_code = self.language_combo_box.itemData(index)
|
||||
if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language:
|
||||
self.parent_app.current_selected_language = selected_lang_code
|
||||
self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code)
|
||||
self.parent_app.settings.sync()
|
||||
self.parent_app.current_selected_language = selected_lang_code
|
||||
|
||||
self._retranslate_ui()
|
||||
|
||||
if hasattr(self.parent_app, '_retranslate_main_ui'):
|
||||
self.parent_app._retranslate_main_ui()
|
||||
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Information)
|
||||
msg_box.setWindowTitle(self._tr("language_change_title", "Language Changed"))
|
||||
@@ -182,23 +255,21 @@ class FutureSettingsDialog(QDialog):
|
||||
self.parent_app._request_restart_application()
|
||||
|
||||
def _save_download_path(self):
|
||||
"""Saves the current download path from the main window to settings."""
|
||||
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
|
||||
current_path = self.parent_app.dir_input.text().strip()
|
||||
if current_path:
|
||||
if os.path.isdir(current_path):
|
||||
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
||||
self.parent_app.settings.sync()
|
||||
QMessageBox.information(self,
|
||||
self._tr("settings_save_path_success_title", "Path Saved"),
|
||||
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_invalid_title", "Invalid Path"),
|
||||
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
if current_path and os.path.isdir(current_path):
|
||||
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
||||
self.parent_app.settings.sync()
|
||||
QMessageBox.information(self,
|
||||
self._tr("settings_save_path_success_title", "Path Saved"),
|
||||
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
|
||||
elif not current_path:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_empty_title", "Empty Path"),
|
||||
self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_invalid_title", "Invalid Path"),
|
||||
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
|
||||
else:
|
||||
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")
|
||||
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")
|
||||
@@ -20,17 +20,20 @@ class TourStepWidget(QWidget):
|
||||
A custom widget representing a single step or page in the feature guide.
|
||||
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)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(10)
|
||||
|
||||
title_font_size = int(14 * scale)
|
||||
content_font_size = int(11 * scale)
|
||||
|
||||
title_label = QLabel(title_text)
|
||||
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)
|
||||
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
@@ -42,8 +45,8 @@ class TourStepWidget(QWidget):
|
||||
content_label.setWordWrap(True)
|
||||
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
content_label.setTextFormat(Qt.RichText)
|
||||
content_label.setOpenExternalLinks(True) # Allow opening links in the content
|
||||
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
|
||||
content_label.setOpenExternalLinks(True)
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
|
||||
scroll_area.setWidget(content_label)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
@@ -56,27 +59,38 @@ class HelpGuideDialog (QDialog ):
|
||||
self .steps_data =steps_data
|
||||
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():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
self .setModal (True )
|
||||
self .setFixedSize (650 ,600 )
|
||||
self.setModal(True)
|
||||
self.resize(int(650 * scale), int(600 * scale))
|
||||
|
||||
dialog_font_size = int(11 * scale)
|
||||
|
||||
current_theme_style = ""
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
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; }}
|
||||
"""
|
||||
|
||||
current_theme_style =""
|
||||
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 =self .parent_app .get_dark_theme ()
|
||||
|
||||
|
||||
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.setStyleSheet(current_theme_style)
|
||||
self ._init_ui ()
|
||||
if self .parent_app :
|
||||
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
|
||||
@@ -97,10 +111,11 @@ class HelpGuideDialog (QDialog ):
|
||||
main_layout .addWidget (self .stacked_widget ,1 )
|
||||
|
||||
self .tour_steps_widgets =[]
|
||||
for title ,content in self .steps_data :
|
||||
step_widget =TourStepWidget (title ,content )
|
||||
self .tour_steps_widgets .append (step_widget )
|
||||
self .stacked_widget .addWidget (step_widget )
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
for title, content in self.steps_data:
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
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"))
|
||||
|
||||
@@ -115,7 +130,6 @@ class HelpGuideDialog (QDialog ):
|
||||
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
|
||||
assets_base_dir =sys ._MEIPASS
|
||||
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__), '..', '..', '..'))
|
||||
|
||||
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 .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 .instagram_button .setIconSize (icon_size )
|
||||
self .Discord_button .setIconSize (icon_size )
|
||||
|
||||
122
src/ui/dialogs/KeepDuplicatesDialog.py
Normal file
122
src/ui/dialogs/KeepDuplicatesDialog.py
Normal 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}
|
||||
@@ -14,7 +14,6 @@ class KnownNamesFilterDialog(QDialog):
|
||||
"""
|
||||
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
|
||||
|
||||
to reuse their saved names and groups for filtering downloads.
|
||||
"""
|
||||
|
||||
@@ -40,11 +39,10 @@ class KnownNamesFilterDialog(QDialog):
|
||||
|
||||
# Set window size dynamically
|
||||
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
base_width, base_height = 460, 450
|
||||
scale_factor_h = screen_geometry.height() / 1080.0
|
||||
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5))
|
||||
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))
|
||||
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))
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -153,4 +151,4 @@ class KnownNamesFilterDialog(QDialog):
|
||||
|
||||
def get_selected_entries(self):
|
||||
"""Returns the list of known name entries selected by the user."""
|
||||
return self.selected_entries_to_return
|
||||
return self.selected_entries_to_return
|
||||
@@ -74,4 +74,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -1,14 +1,35 @@
|
||||
# src/ui/dialogs/SupportDialog.py
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
# --- Standard Library Imports ---
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 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
|
||||
|
||||
# --- 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):
|
||||
"""
|
||||
A dialog to show support and donation options.
|
||||
@@ -17,11 +38,16 @@ class SupportDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
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
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15)
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setSpacing(15)
|
||||
|
||||
# Title Label
|
||||
title_label = QLabel("Thank You for Your Support!")
|
||||
@@ -30,7 +56,7 @@ class SupportDialog(QDialog):
|
||||
font.setBold(True)
|
||||
title_label.setFont(font)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
main_layout.addWidget(title_label)
|
||||
|
||||
# Informational Text
|
||||
info_label = QLabel(
|
||||
@@ -39,50 +65,86 @@ class SupportDialog(QDialog):
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(info_label)
|
||||
main_layout.addWidget(info_label)
|
||||
|
||||
# Separator
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
layout.addWidget(line)
|
||||
main_layout.addWidget(line)
|
||||
|
||||
# Donation Options
|
||||
options_layout = QVBoxLayout()
|
||||
options_layout.setSpacing(10)
|
||||
# --- Donation Options Layout (using a grid for icons and text) ---
|
||||
options_layout = QGridLayout()
|
||||
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 ---
|
||||
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;">'
|
||||
'☕ Buy me a Ko-fi'
|
||||
'</a>'
|
||||
)
|
||||
kofi_label.setOpenExternalLinks(True)
|
||||
kofi_label.setAlignment(Qt.AlignCenter)
|
||||
font.setPointSize(12)
|
||||
kofi_label.setFont(font)
|
||||
options_layout.addWidget(kofi_label)
|
||||
kofi_text_label.setOpenExternalLinks(True)
|
||||
kofi_text_label.setFont(link_font)
|
||||
|
||||
options_layout.addWidget(kofi_icon_label, 0, 1, Qt.AlignRight | Qt.AlignVCenter)
|
||||
options_layout.addWidget(kofi_text_label, 0, 2, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
# --- GitHub Sponsors ---
|
||||
github_label = QLabel(
|
||||
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #C9D1D9; text-decoration: none;">'
|
||||
github_icon_label = QLabel()
|
||||
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'
|
||||
'</a>'
|
||||
)
|
||||
github_label.setOpenExternalLinks(True)
|
||||
github_label.setAlignment(Qt.AlignCenter)
|
||||
github_label.setFont(font)
|
||||
options_layout.addWidget(github_label)
|
||||
github_text_label.setOpenExternalLinks(True)
|
||||
github_text_label.setFont(link_font)
|
||||
|
||||
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
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
main_layout.addWidget(self.button_box)
|
||||
|
||||
self.setLayout(layout)
|
||||
self._apply_theme()
|
||||
self.setLayout(main_layout)
|
||||
|
||||
def _apply_theme(self):
|
||||
"""Applies the current theme from the parent application."""
|
||||
@@ -90,4 +152,4 @@ class SupportDialog(QDialog):
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
self.setStyleSheet("")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ MAX_FILENAME_COMPONENT_LENGTH = 150
|
||||
|
||||
# Sets of file extensions for quick type checking
|
||||
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'
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
|
||||
@@ -10,7 +10,7 @@ from PyQt5.QtWidgets import (
|
||||
QListWidget, QTextEdit, QApplication
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QIntValidator # <-- Import QIntValidator from here
|
||||
from PyQt5.QtGui import QIntValidator, QFont # <-- Import QFont
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# Assuming execution from project root
|
||||
@@ -24,20 +24,19 @@ def setup_ui(main_app):
|
||||
Args:
|
||||
main_app: The instance of the main DownloaderApp.
|
||||
"""
|
||||
# --- START: New Scaling Logic ---
|
||||
screen = QApplication.primaryScreen()
|
||||
if screen:
|
||||
resolution = screen.size()
|
||||
if resolution.width() > 1920 and resolution.height() > 1200:
|
||||
main_app.scale_factor = 2
|
||||
else:
|
||||
main_app.scale_factor = 1
|
||||
else:
|
||||
# Fallback if a primary screen isn't detected
|
||||
main_app.scale_factor = 1
|
||||
|
||||
scale = main_app.scale_factor # Use a convenient local variable
|
||||
# --- END: New Scaling Logic ---
|
||||
scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0))
|
||||
main_app.scale_factor = scale
|
||||
|
||||
default_font = QApplication.font()
|
||||
base_font_size = 9 # Use a standard base size
|
||||
default_font.setPointSize(int(base_font_size * scale))
|
||||
main_app.setFont(default_font)
|
||||
|
||||
default_font = QApplication.font()
|
||||
base_font_size = 9 # Use a standard base size
|
||||
default_font.setPointSize(int(base_font_size * scale))
|
||||
main_app.setFont(default_font)
|
||||
# --- END: Improved Scaling Logic ---
|
||||
|
||||
main_app.main_splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
@@ -68,7 +67,11 @@ def setup_ui(main_app):
|
||||
main_app.link_input.textChanged.connect(main_app.update_custom_folder_visibility)
|
||||
url_input_layout.addWidget(main_app.link_input, 1)
|
||||
main_app.empty_popup_button = QPushButton("🎨")
|
||||
main_app.empty_popup_button.setStyleSheet(f"padding: {4*scale}px {6*scale}px;")
|
||||
special_font_size = int(9.5 * scale)
|
||||
main_app.empty_popup_button.setStyleSheet(f"""
|
||||
padding: {4*scale}px {6*scale}px;
|
||||
font-size: {special_font_size}pt;
|
||||
""")
|
||||
main_app.empty_popup_button.clicked.connect(main_app._show_empty_popup)
|
||||
url_input_layout.addWidget(main_app.empty_popup_button)
|
||||
main_app.page_range_label = QLabel(main_app._tr("page_range_label_text", "Page Range:"))
|
||||
@@ -76,14 +79,14 @@ def setup_ui(main_app):
|
||||
url_input_layout.addWidget(main_app.page_range_label)
|
||||
main_app.start_page_input = QLineEdit()
|
||||
main_app.start_page_input.setPlaceholderText(main_app._tr("start_page_input_placeholder", "Start"))
|
||||
main_app.start_page_input.setFixedWidth(50 * scale)
|
||||
main_app.start_page_input.setFixedWidth(int(50 * scale))
|
||||
main_app.start_page_input.setValidator(QIntValidator(1, 99999))
|
||||
url_input_layout.addWidget(main_app.start_page_input)
|
||||
main_app.to_label = QLabel(main_app._tr("page_range_to_label_text", "to"))
|
||||
url_input_layout.addWidget(main_app.to_label)
|
||||
main_app.end_page_input = QLineEdit()
|
||||
main_app.end_page_input.setPlaceholderText(main_app._tr("end_page_input_placeholder", "End"))
|
||||
main_app.end_page_input.setFixedWidth(50 * scale)
|
||||
main_app.end_page_input.setFixedWidth(int(50 * scale))
|
||||
main_app.end_page_input.setToolTip(main_app._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number..."))
|
||||
main_app.end_page_input.setValidator(QIntValidator(1, 99999))
|
||||
url_input_layout.addWidget(main_app.end_page_input)
|
||||
@@ -213,12 +216,10 @@ def setup_ui(main_app):
|
||||
checkboxes_group_layout.setSpacing(10)
|
||||
row1_layout = QHBoxLayout()
|
||||
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)
|
||||
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")
|
||||
row1_layout.addWidget(main_app.download_thumbnails_checkbox)
|
||||
main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images")
|
||||
@@ -238,7 +239,7 @@ def setup_ui(main_app):
|
||||
checkboxes_group_layout.addWidget(advanced_settings_label)
|
||||
advanced_row1_layout = QHBoxLayout()
|
||||
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.toggled.connect(main_app.update_ui_for_subfolders)
|
||||
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
|
||||
@@ -256,7 +257,7 @@ def setup_ui(main_app):
|
||||
advanced_row1_layout.addWidget(main_app.use_cookie_checkbox)
|
||||
advanced_row1_layout.addWidget(main_app.cookie_text_input, 2)
|
||||
main_app.cookie_browse_button = QPushButton("Browse...")
|
||||
main_app.cookie_browse_button.setFixedWidth(80 * scale)
|
||||
main_app.cookie_browse_button.setFixedWidth(int(80 * scale))
|
||||
advanced_row1_layout.addWidget(main_app.cookie_browse_button)
|
||||
advanced_row1_layout.addStretch(1)
|
||||
checkboxes_group_layout.addLayout(advanced_row1_layout)
|
||||
@@ -270,7 +271,7 @@ def setup_ui(main_app):
|
||||
main_app.thread_count_label = QLabel("Threads:")
|
||||
multithreading_layout.addWidget(main_app.thread_count_label)
|
||||
main_app.thread_count_input = QLineEdit("4")
|
||||
main_app.thread_count_input.setFixedWidth(40 * scale)
|
||||
main_app.thread_count_input.setFixedWidth(int(40 * scale))
|
||||
main_app.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS))
|
||||
multithreading_layout.addWidget(main_app.thread_count_input)
|
||||
advanced_row2_layout.addLayout(multithreading_layout)
|
||||
@@ -288,7 +289,9 @@ def setup_ui(main_app):
|
||||
btn_layout.setContentsMargins(0, 10, 0, 0)
|
||||
btn_layout.setSpacing(10)
|
||||
main_app.download_btn = QPushButton("⬇️ Start Download")
|
||||
main_app.download_btn.setStyleSheet("font-weight: bold;")
|
||||
font = main_app.download_btn.font()
|
||||
font.setBold(True)
|
||||
main_app.download_btn.setFont(font)
|
||||
main_app.download_btn.clicked.connect(main_app.start_download)
|
||||
main_app.pause_btn = QPushButton("⏸️ Pause Download")
|
||||
main_app.pause_btn.setEnabled(False)
|
||||
@@ -323,7 +326,7 @@ def setup_ui(main_app):
|
||||
main_app.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):")
|
||||
known_chars_label_layout.addWidget(main_app.known_chars_label)
|
||||
main_app.open_known_txt_button = QPushButton("Open Known.txt")
|
||||
main_app.open_known_txt_button.setFixedWidth(120 * scale)
|
||||
main_app.open_known_txt_button.setFixedWidth(int(120 * scale))
|
||||
known_chars_label_layout.addWidget(main_app.open_known_txt_button)
|
||||
main_app.character_search_input = QLineEdit()
|
||||
main_app.character_search_input.setPlaceholderText("Search characters...")
|
||||
@@ -347,23 +350,23 @@ def setup_ui(main_app):
|
||||
char_manage_layout.addWidget(main_app.new_char_input, 2)
|
||||
char_manage_layout.addWidget(main_app.add_char_button, 0)
|
||||
main_app.known_names_help_button = QPushButton("?")
|
||||
main_app.known_names_help_button.setFixedWidth(45 * scale)
|
||||
main_app.known_names_help_button.setFixedWidth(int(45 * scale))
|
||||
main_app.known_names_help_button.clicked.connect(main_app._show_feature_guide)
|
||||
main_app.history_button = QPushButton("📜")
|
||||
main_app.history_button.setFixedWidth(45 * scale)
|
||||
main_app.history_button.setFixedWidth(int(45 * scale))
|
||||
main_app.history_button.setToolTip(main_app._tr("history_button_tooltip_text", "View download history"))
|
||||
main_app.future_settings_button = QPushButton("⚙️")
|
||||
main_app.future_settings_button.setFixedWidth(45 * scale)
|
||||
main_app.future_settings_button.setFixedWidth(int(45 * scale))
|
||||
main_app.future_settings_button.clicked.connect(main_app._show_future_settings_dialog)
|
||||
main_app.support_button = QPushButton("❤️ Support")
|
||||
main_app.support_button.setFixedWidth(100 * scale)
|
||||
main_app.support_button.setFixedWidth(int(100 * scale))
|
||||
main_app.support_button.setToolTip("Support the application developer.")
|
||||
char_manage_layout.addWidget(main_app.add_to_filter_button, 1)
|
||||
char_manage_layout.addWidget(main_app.delete_char_button, 1)
|
||||
char_manage_layout.addWidget(main_app.known_names_help_button, 0)
|
||||
char_manage_layout.addWidget(main_app.history_button, 0)
|
||||
char_manage_layout.addWidget(main_app.future_settings_button, 0)
|
||||
char_manage_layout.addWidget(main_app.support_button, 0)
|
||||
char_manage_layout.addWidget(main_app.support_button, 0)
|
||||
left_layout.addLayout(char_manage_layout)
|
||||
left_layout.addStretch(0)
|
||||
|
||||
@@ -379,11 +382,11 @@ def setup_ui(main_app):
|
||||
log_title_layout.addWidget(main_app.link_search_input)
|
||||
main_app.link_search_button = QPushButton("🔍")
|
||||
main_app.link_search_button.setVisible(False)
|
||||
main_app.link_search_button.setFixedWidth(30 * scale)
|
||||
main_app.link_search_button.setFixedWidth(int(30 * scale))
|
||||
log_title_layout.addWidget(main_app.link_search_button)
|
||||
main_app.manga_rename_toggle_button = QPushButton()
|
||||
main_app.manga_rename_toggle_button.setVisible(False)
|
||||
main_app.manga_rename_toggle_button.setFixedWidth(140 * scale)
|
||||
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))
|
||||
main_app._update_manga_filename_style_button_text()
|
||||
log_title_layout.addWidget(main_app.manga_rename_toggle_button)
|
||||
main_app.manga_date_prefix_input = QLineEdit()
|
||||
@@ -392,17 +395,17 @@ def setup_ui(main_app):
|
||||
log_title_layout.addWidget(main_app.manga_date_prefix_input)
|
||||
main_app.multipart_toggle_button = QPushButton()
|
||||
main_app.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.")
|
||||
main_app.multipart_toggle_button.setFixedWidth(130 * scale)
|
||||
main_app.multipart_toggle_button.setFixedWidth(int(130 * scale))
|
||||
main_app._update_multipart_toggle_button_text()
|
||||
log_title_layout.addWidget(main_app.multipart_toggle_button)
|
||||
main_app.EYE_ICON = "\U0001F441"
|
||||
main_app.CLOSED_EYE_ICON = "\U0001F648"
|
||||
main_app.log_verbosity_toggle_button = QPushButton(main_app.EYE_ICON)
|
||||
main_app.log_verbosity_toggle_button.setFixedWidth(45 * scale)
|
||||
main_app.log_verbosity_toggle_button.setFixedWidth(int(45 * scale))
|
||||
main_app.log_verbosity_toggle_button.setStyleSheet(f"font-size: {11 * scale}pt; padding: {4 * scale}px {2 * scale}px;")
|
||||
log_title_layout.addWidget(main_app.log_verbosity_toggle_button)
|
||||
main_app.reset_button = QPushButton("🔄 Reset")
|
||||
main_app.reset_button.setFixedWidth(80 * scale)
|
||||
main_app.reset_button.setFixedWidth(int(80 * scale))
|
||||
log_title_layout.addWidget(main_app.reset_button)
|
||||
right_layout.addLayout(log_title_layout)
|
||||
main_app.log_splitter = QSplitter(Qt.Vertical)
|
||||
@@ -426,17 +429,17 @@ def setup_ui(main_app):
|
||||
export_button_layout = QHBoxLayout()
|
||||
export_button_layout.addStretch(1)
|
||||
main_app.export_links_button = QPushButton(main_app._tr("export_links_button_text", "Export Links"))
|
||||
main_app.export_links_button.setFixedWidth(100 * scale)
|
||||
main_app.export_links_button.setFixedWidth(int(100 * scale))
|
||||
main_app.export_links_button.setEnabled(False)
|
||||
main_app.export_links_button.setVisible(False)
|
||||
export_button_layout.addWidget(main_app.export_links_button)
|
||||
main_app.download_extracted_links_button = QPushButton(main_app._tr("download_extracted_links_button_text", "Download"))
|
||||
main_app.download_extracted_links_button.setFixedWidth(100 * scale)
|
||||
main_app.download_extracted_links_button.setFixedWidth(int(100 * scale))
|
||||
main_app.download_extracted_links_button.setEnabled(False)
|
||||
main_app.download_extracted_links_button.setVisible(False)
|
||||
export_button_layout.addWidget(main_app.download_extracted_links_button)
|
||||
main_app.log_display_mode_toggle_button = QPushButton()
|
||||
main_app.log_display_mode_toggle_button.setFixedWidth(120 * scale)
|
||||
main_app.log_display_mode_toggle_button.setFixedWidth(int(120 * scale))
|
||||
main_app.log_display_mode_toggle_button.setVisible(False)
|
||||
export_button_layout.addWidget(main_app.log_display_mode_toggle_button)
|
||||
right_layout.addLayout(export_button_layout)
|
||||
@@ -453,23 +456,15 @@ def setup_ui(main_app):
|
||||
main_app.main_splitter.addWidget(left_scroll_area)
|
||||
main_app.main_splitter.addWidget(right_panel_widget)
|
||||
|
||||
# --- START: Resolution-based Splitter Sizing ---
|
||||
# Check screen resolution to set the initial splitter sizes
|
||||
if screen:
|
||||
resolution = screen.size()
|
||||
if resolution.width() >= 1920 and resolution.height() >= 1200:
|
||||
# For 1920x1200 and higher, set 40% left, 60% right
|
||||
main_app.main_splitter.setStretchFactor(0, 4)
|
||||
main_app.main_splitter.setStretchFactor(1, 6)
|
||||
else:
|
||||
# Default for lower resolutions
|
||||
main_app.main_splitter.setStretchFactor(0, 7)
|
||||
main_app.main_splitter.setStretchFactor(1, 3)
|
||||
if main_app.width() >= 1920:
|
||||
# For wider resolutions, give more space to the log panel (right).
|
||||
main_app.main_splitter.setStretchFactor(0, 4)
|
||||
main_app.main_splitter.setStretchFactor(1, 6)
|
||||
else:
|
||||
# Fallback if no screen is detected
|
||||
# Default for lower resolutions, giving more space to controls (left).
|
||||
main_app.main_splitter.setStretchFactor(0, 7)
|
||||
main_app.main_splitter.setStretchFactor(1, 3)
|
||||
# --- END: Resolution-based Splitter Sizing ---
|
||||
|
||||
|
||||
top_level_layout = QHBoxLayout(main_app)
|
||||
top_level_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -505,46 +500,65 @@ def get_dark_theme(scale=1):
|
||||
"""
|
||||
Generates the stylesheet for the dark theme, scaled by the given factor.
|
||||
"""
|
||||
# Define base sizes
|
||||
font_size_base = 10
|
||||
font_size_small_base = 9.5
|
||||
padding_base = 5
|
||||
padding_small = 4
|
||||
button_h_padding_base = 12
|
||||
indicator_size_base = 14
|
||||
# Adjust base font size for better readability
|
||||
font_size_base = 9.5
|
||||
font_size_small_base = 8.5
|
||||
|
||||
# Apply scaling
|
||||
font_size = font_size_base * scale
|
||||
font_size_small = font_size_small_base * scale
|
||||
line_edit_padding = padding_base * scale
|
||||
button_padding_v = padding_base * scale
|
||||
button_padding_h = button_h_padding_base * scale
|
||||
tooltip_padding = padding_small * scale
|
||||
font_size = int(font_size_base * scale)
|
||||
font_size_small = int(font_size_small_base * scale)
|
||||
line_edit_padding = int(5 * scale)
|
||||
button_padding_v = int(5 * scale)
|
||||
button_padding_h = int(12 * scale)
|
||||
tooltip_padding = int(4 * scale)
|
||||
indicator_size = int(14 * scale)
|
||||
|
||||
return f"""
|
||||
QWidget {{ background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: {font_size}pt; }}
|
||||
QLineEdit, QListWidget {{ background-color: #3C3F41; border: 1px solid #5A5A5A; padding: {line_edit_padding}px; color: #F0F0F0; border-radius: 4px; }}
|
||||
QTextEdit {{ background-color: #3C3F41; border: 1px solid #5A5A5A; padding: {line_edit_padding}px;
|
||||
color: #F0F0F0; border-radius: 4px;
|
||||
font-family: Consolas, Courier New, monospace; font-size: {font_size_small}pt; }}
|
||||
QPushButton {{ background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: {button_padding_v}px {button_padding_h}px; border-radius: 4px; }}
|
||||
QWidget {{
|
||||
background-color: #2E2E2E;
|
||||
color: #E0E0E0;
|
||||
font-family: Segoe UI, Arial, sans-serif;
|
||||
font-size: {font_size}pt;
|
||||
}}
|
||||
QLineEdit, QListWidget, QTextEdit {{
|
||||
background-color: #3C3F41;
|
||||
border: 1px solid #5A5A5A;
|
||||
padding: {line_edit_padding}px;
|
||||
color: #F0F0F0;
|
||||
border-radius: 4px;
|
||||
font-size: {font_size}pt;
|
||||
}}
|
||||
QTextEdit {{
|
||||
font-family: Consolas, Courier New, monospace;
|
||||
}}
|
||||
QPushButton {{
|
||||
background-color: #555;
|
||||
color: #F0F0F0;
|
||||
border: 1px solid #6A6A6A;
|
||||
padding: {button_padding_v}px {button_padding_h}px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #656565; border: 1px solid #7A7A7A; }}
|
||||
QPushButton:pressed {{ background-color: #4A4A4A; }}
|
||||
QPushButton:disabled {{ background-color: #404040; color: #888; border-color: #555; }}
|
||||
QLabel {{ font-weight: bold; padding-top: {4 * scale}px; padding-bottom: {2 * scale}px; color: #C0C0C0; }}
|
||||
QRadioButton, QCheckBox {{ spacing: {5 * scale}px; color: #E0E0E0; padding-top: {4 * scale}px; padding-bottom: {4 * scale}px; }}
|
||||
QRadioButton::indicator, QCheckBox::indicator {{ width: {indicator_size_base * scale}px; height: {indicator_size_base * scale}px; }}
|
||||
QListWidget {{ alternate-background-color: #353535; border: 1px solid #5A5A5A; }}
|
||||
QLabel {{ font-weight: bold; color: #C0C0C0; }}
|
||||
QRadioButton, QCheckBox {{ spacing: {int(5 * scale)}px; color: #E0E0E0; }}
|
||||
QRadioButton::indicator, QCheckBox::indicator {{ width: {indicator_size}px; height: {indicator_size}px; }}
|
||||
QListWidget {{ alternate-background-color: #353535; }}
|
||||
QListWidget::item:selected {{ background-color: #007ACC; color: #FFFFFF; }}
|
||||
QToolTip {{ background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: {tooltip_padding}px; border-radius: 3px; }}
|
||||
QSplitter::handle {{ background-color: #5A5A5A; }}
|
||||
QSplitter::handle:horizontal {{ width: {5 * scale}px; }}
|
||||
QSplitter::handle:vertical {{ height: {5 * scale}px; }}
|
||||
QFrame[frameShape="4"], QFrame[frameShape="5"] {{
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 3px;
|
||||
QToolTip {{
|
||||
background-color: #4A4A4A;
|
||||
color: #F0F0F0;
|
||||
border: 1px solid #6A6A6A;
|
||||
padding: {tooltip_padding}px;
|
||||
border-radius: 3px;
|
||||
font-size: {font_size}pt;
|
||||
}}
|
||||
QSplitter::handle {{ background-color: #5A5A5A; }}
|
||||
QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }}
|
||||
QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }}
|
||||
"""
|
||||
|
||||
def apply_theme_to_app(main_app, theme_name, initial_load=False):
|
||||
"""
|
||||
Applies the selected theme and scaling to the main application window.
|
||||
|
||||
2064
workers.py
2064
workers.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user