38 Commits

Author SHA1 Message Date
Yuvi9587
fbdae61b80 Commit 2025-07-19 03:28:32 -07:00
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
Yuvi9587
8ba2a572fa Update readme.md 2025-07-16 09:51:04 -07:00
Yuvi9587
8db40f03b6 Update readme.md 2025-07-16 09:50:41 -07:00
Yuvi9587
742fe7685c Update readme.md 2025-07-16 09:49:47 -07:00
Yuvi9587
e085d9a134 Update readme.md 2025-07-16 09:49:05 -07:00
Yuvi9587
1cd03731c0 Update readme.md 2025-07-16 09:47:51 -07:00
Yuvi9587
0bc8d7c692 Update readme.md 2025-07-16 09:47:07 -07:00
Yuvi9587
3a9009e76e Update readme.md 2025-07-16 09:45:40 -07:00
Yuvi9587
9a28e922b4 Commit 2025-07-16 09:42:52 -07:00
Yuvi9587
923a0ff61e Update readme.md 2025-07-16 09:41:37 -07:00
Yuvi9587
e891a2a845 Update readme.md 2025-07-16 09:41:18 -07:00
Yuvi9587
778b0219e2 Update readme.md 2025-07-16 09:39:58 -07:00
Yuvi9587
3fc08d9ea7 Commit 2025-07-16 09:39:07 -07:00
Yuvi9587
af6a6add57 Update readme.md 2025-07-16 09:35:30 -07:00
Yuvi9587
7737d32ef9 Update readme.md 2025-07-16 09:34:22 -07:00
Yuvi9587
c08cbb6490 Update readme.md 2025-07-16 09:30:43 -07:00
Yuvi9587
92a2e91624 Update readme.md 2025-07-16 09:29:46 -07:00
Yuvi9587
11ea511a9d Update readme.md 2025-07-16 09:28:48 -07:00
Yuvi9587
8abdb49ed8 Update readme.md 2025-07-16 09:27:51 -07:00
Yuvi9587
0873dd1ce0 Update readme.md 2025-07-16 09:27:26 -07:00
Yuvi9587
df5fbc1f73 Update readme.md 2025-07-16 09:25:51 -07:00
Yuvi9587
5510f7f0c6 Update readme.md 2025-07-16 09:25:29 -07:00
Yuvi9587
2f0593c450 Update readme.md 2025-07-16 09:23:27 -07:00
Yuvi9587
e67adb6bdc Update readme.md 2025-07-16 09:23:02 -07:00
Yuvi9587
d39081088c Update FUNDING.yml 2025-07-16 09:21:06 -07:00
Yuvi9587
f303b8b020 Commit 2025-07-16 09:02:47 -07:00
Yuvi9587
539e76aa9e Delete workers.py 2025-07-15 21:09:16 -07:00
Yuvi9587
574d0d66b4 Commit 2025-07-15 21:08:11 -07:00
Yuvi9587
9e58a9d574 commit 2025-07-15 08:49:20 -07:00
Yuvi9587
d67de87a11 Commit 2025-07-15 07:14:40 -07:00
Yuvi9587
149f217f2f Commit 2025-07-15 07:05:36 -07:00
Yuvi9587
874902ad60 Commit 2025-07-15 06:54:31 -07:00
Yuvi9587
440cf60d90 Update MoreOptionsDialog.py 2025-07-14 20:18:04 -07:00
Yuvi9587
fb446a1e28 Commit 2025-07-14 20:17:48 -07:00
Yuvi9587
cfd869e05a Update main_window.py 2025-07-14 09:04:34 -07:00
Yuvi9587
b191776f65 Commit 2025-07-14 08:19:58 -07:00
30 changed files with 4321 additions and 9573 deletions

2
.github/FUNDING.yml vendored
View File

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

BIN
Read/bmac.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -57,6 +57,8 @@ THEME_KEY = "currentThemeV2"
SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1" SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1"
LANGUAGE_KEY = "currentLanguageV1" LANGUAGE_KEY = "currentLanguageV1"
DOWNLOAD_LOCATION_KEY = "downloadLocationV1" DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
# --- UI Constants and Identifiers --- # --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>" HTML_PREFIX = "<!HTML!>"
@@ -70,7 +72,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
# --- File Type Extensions --- # --- File Type Extensions ---
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif' '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
} }
VIDEO_EXTENSIONS = { VIDEO_EXTENSIONS = {
@@ -111,3 +113,7 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
"fri", "friday", "sat", "saturday", "sun", "sunday" "fri", "friday", "sat", "saturday", "sun", "sunday"
# add more according to need # add more according to need
} }
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

View File

@@ -3,8 +3,6 @@ import traceback
from urllib.parse import urlparse from urllib.parse import urlparse
import json # Ensure json is imported import json # Ensure json is imported
import requests import requests
# (Keep the rest of your imports)
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import ( from ..config.constants import (
STYLE_DATE_POST_TITLE STYLE_DATE_POST_TITLE
@@ -25,9 +23,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
raise RuntimeError("Fetch operation cancelled by user while paused.") raise RuntimeError("Fetch operation cancelled by user while paused.")
time.sleep(0.5) time.sleep(0.5)
logger(" Post fetching resumed.") logger(" Post fetching resumed.")
# --- MODIFICATION: Added `fields` to the URL to request only metadata ---
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags" fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}' paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
@@ -44,7 +39,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(log_message) logger(log_message)
try: try:
# We can now remove the streaming logic as the response will be small and fast.
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@@ -80,7 +74,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}" post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f" Fetching full content for post ID {post_id}...") logger(f" Fetching full content for post ID {post_id}...")
try: try:
# Use streaming here as a precaution for single posts that are still very large.
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response: with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
response.raise_for_status() response.raise_for_status()
response_body = b"" response_body = b""
@@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
response_body += chunk response_body += chunk
full_post_data = json.loads(response_body) full_post_data = json.loads(response_body)
# The API sometimes wraps the post in a list, handle that.
if isinstance(full_post_data, list) and full_post_data: if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0] return full_post_data[0]
return full_post_data return full_post_data
@@ -115,217 +107,237 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
except ValueError as e: except ValueError as e:
raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}") raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}")
def download_from_api ( def download_from_api(
api_url_input , api_url_input,
logger =print , logger=print,
start_page =None , start_page=None,
end_page =None , end_page=None,
manga_mode =False , manga_mode=False,
cancellation_event =None , cancellation_event=None,
pause_event =None , pause_event=None,
use_cookie =False , use_cookie=False,
cookie_text ="", cookie_text="",
selected_cookie_file =None , selected_cookie_file=None,
app_base_dir =None , app_base_dir=None,
manga_filename_style_for_sort_check =None manga_filename_style_for_sort_check=None,
processed_post_ids=None # --- ADD THIS ARGUMENT ---
): ):
headers ={ headers = {
'User-Agent':'Mozilla/5.0', 'User-Agent': 'Mozilla/5.0',
'Accept':'application/json' 'Accept': 'application/json'
} }
if processed_post_ids is None:
processed_post_ids = set()
else:
processed_post_ids = set(processed_post_ids)
service ,user_id ,target_post_id =extract_post_info (api_url_input ) service, user_id, target_post_id = extract_post_info(api_url_input)
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Download_from_api cancelled at start.") logger(" Download_from_api cancelled at start.")
return return
parsed_input_url_for_domain =urlparse (api_url_input ) parsed_input_url_for_domain = urlparse(api_url_input)
api_domain =parsed_input_url_for_domain .netloc api_domain = parsed_input_url_for_domain.netloc
if not any (d in api_domain .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']): if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
logger (f"⚠️ Unrecognized domain '{api_domain }' from input URL. Defaulting to kemono.su for API calls.") logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
api_domain ="kemono.su" api_domain = "kemono.su"
cookies_for_api =None cookies_for_api = None
if use_cookie and app_base_dir : if use_cookie and app_base_dir:
cookies_for_api =prepare_cookies_for_request (use_cookie ,cookie_text ,selected_cookie_file ,app_base_dir ,logger ,target_domain =api_domain ) cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
if target_post_id : if target_post_id:
direct_post_api_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }/post/{target_post_id }" if target_post_id in processed_post_ids:
logger (f" Attempting direct fetch for target post: {direct_post_api_url }") logger(f" Skipping already processed target post ID: {target_post_id}")
try : return
direct_response =requests .get (direct_post_api_url ,headers =headers ,timeout =(10 ,30 ),cookies =cookies_for_api ) direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
direct_response .raise_for_status () logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
direct_post_data =direct_response .json () try:
if isinstance (direct_post_data ,list )and direct_post_data : direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
direct_post_data =direct_post_data [0 ] direct_response.raise_for_status()
if isinstance (direct_post_data ,dict )and 'post'in direct_post_data and isinstance (direct_post_data ['post'],dict ): direct_post_data = direct_response.json()
direct_post_data =direct_post_data ['post'] if isinstance(direct_post_data, list) and direct_post_data:
if isinstance (direct_post_data ,dict )and direct_post_data .get ('id')==target_post_id : direct_post_data = direct_post_data[0]
logger (f" ✅ Direct fetch successful for post {target_post_id }.") if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
yield [direct_post_data ] direct_post_data = direct_post_data['post']
if isinstance(direct_post_data, dict) and direct_post_data.get('id') == target_post_id:
logger(f" ✅ Direct fetch successful for post {target_post_id}.")
yield [direct_post_data]
return return
else : else:
response_type =type (direct_post_data ).__name__ response_type = type(direct_post_data).__name__
response_snippet =str (direct_post_data )[:200 ] response_snippet = str(direct_post_data)[:200]
logger (f" ⚠️ Direct fetch for post {target_post_id } returned unexpected data (Type: {response_type }, Snippet: '{response_snippet }'). Falling back to pagination.") logger(f" ⚠️ Direct fetch for post {target_post_id} returned unexpected data (Type: {response_type}, Snippet: '{response_snippet}'). Falling back to pagination.")
except requests .exceptions .RequestException as e : except requests.exceptions.RequestException as e:
logger (f" ⚠️ Direct fetch failed for post {target_post_id }: {e }. Falling back to pagination.") logger(f" ⚠️ Direct fetch failed for post {target_post_id}: {e}. Falling back to pagination.")
except Exception as e : except Exception as e:
logger (f" ⚠️ Unexpected error during direct fetch for post {target_post_id }: {e }. Falling back to pagination.") logger(f" ⚠️ Unexpected error during direct fetch for post {target_post_id}: {e}. Falling back to pagination.")
if not service or not user_id : if not service or not user_id:
logger (f"❌ Invalid URL or could not extract service/user: {api_url_input }") logger(f"❌ Invalid URL or could not extract service/user: {api_url_input}")
return return
if target_post_id and (start_page or end_page ): if target_post_id and (start_page or end_page):
logger ("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).") logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
is_manga_mode_fetch_all_and_sort_oldest_first =manga_mode and (manga_filename_style_for_sort_check !=STYLE_DATE_POST_TITLE )and not target_post_id is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id
api_base_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }" api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
page_size =50 page_size = 50
if is_manga_mode_fetch_all_and_sort_oldest_first : if is_manga_mode_fetch_all_and_sort_oldest_first:
logger (f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...") logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
all_posts_for_manga_mode =[] all_posts_for_manga_mode = []
current_offset_manga =0 current_offset_manga = 0
if start_page and start_page >1 : if start_page and start_page > 1:
current_offset_manga =(start_page -1 )*page_size current_offset_manga = (start_page - 1) * page_size
logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).") logger(f" Manga Mode: Starting fetch from page {start_page} (offset {current_offset_manga}).")
elif start_page : elif start_page:
logger (f" Manga Mode: Starting fetch from page 1 (offset 0).") logger(f" Manga Mode: Starting fetch from page 1 (offset 0).")
if end_page : if end_page:
logger (f" Manga Mode: Will fetch up to page {end_page }.") logger(f" Manga Mode: Will fetch up to page {end_page}.")
while True : while True:
if pause_event and pause_event .is_set (): if pause_event and pause_event.is_set():
logger (" Manga mode post fetching paused...") logger(" Manga mode post fetching paused...")
while pause_event .is_set (): while pause_event.is_set():
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Manga mode post fetching cancelled while paused.") logger(" Manga mode post fetching cancelled while paused.")
break break
time .sleep (0.5 ) time.sleep(0.5)
if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.") if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Manga mode post fetching cancelled.") logger(" Manga mode post fetching cancelled.")
break break
current_page_num_manga =(current_offset_manga //page_size )+1 current_page_num_manga = (current_offset_manga // page_size) + 1
if end_page and current_page_num_manga >end_page : if end_page and current_page_num_manga > end_page:
logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.") logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break break
try : try:
posts_batch_manga =fetch_posts_paginated (api_base_url ,headers ,current_offset_manga ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance (posts_batch_manga ,list ): if not isinstance(posts_batch_manga, list):
logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.") logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break break
if not posts_batch_manga : if not posts_batch_manga:
logger ("✅ Reached end of posts (Manga Mode fetch all).") logger("✅ Reached end of posts (Manga Mode fetch all).")
if start_page and not end_page and current_page_num_manga <start_page : if start_page and not end_page and current_page_num_manga < start_page:
logger (f" Manga Mode: No posts found on or after specified start page {start_page }.") logger(f" Manga Mode: No posts found on or after specified start page {start_page}.")
elif end_page and current_page_num_manga <=end_page and not all_posts_for_manga_mode : elif end_page and current_page_num_manga <= end_page and not all_posts_for_manga_mode:
logger (f" Manga Mode: No posts found within the specified page range ({start_page or 1 }-{end_page }).") logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
break break
all_posts_for_manga_mode .extend (posts_batch_manga ) all_posts_for_manga_mode.extend(posts_batch_manga)
current_offset_manga +=page_size current_offset_manga += page_size
time .sleep (0.6 ) time.sleep(0.6)
except RuntimeError as e : except RuntimeError as e:
if "cancelled by user"in str (e ).lower (): if "cancelled by user" in str(e).lower():
logger (f" Manga mode pagination stopped due to cancellation: {e }") logger(f" Manga mode pagination stopped due to cancellation: {e}")
else : else:
logger (f"{e }\n Aborting manga mode pagination.") logger(f"{e}\n Aborting manga mode pagination.")
break break
except Exception as e : except Exception as e:
logger (f"❌ Unexpected error during manga mode fetch: {e }") logger(f"❌ Unexpected error during manga mode fetch: {e}")
traceback .print_exc () traceback.print_exc()
break break
if cancellation_event and cancellation_event .is_set ():return if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode : if all_posts_for_manga_mode:
logger (f" Manga Mode: Fetched {len (all_posts_for_manga_mode )} total posts. Sorting by publication date (oldest first)...") if processed_post_ids:
def sort_key_tuple (post ): original_count = len(all_posts_for_manga_mode)
published_date_str =post .get ('published') all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
added_date_str =post .get ('added') skipped_count = original_count - len(all_posts_for_manga_mode)
post_id_str =post .get ('id',"0") if skipped_count > 0:
primary_sort_val ="0000-00-00T00:00:00" logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
if published_date_str :
primary_sort_val =published_date_str logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
elif added_date_str : def sort_key_tuple(post):
logger (f" ⚠️ Post ID {post_id_str } missing 'published' date, using 'added' date '{added_date_str }' for primary sorting.") published_date_str = post.get('published')
primary_sort_val =added_date_str added_date_str = post.get('added')
else : post_id_str = post.get('id', "0")
logger (f" ⚠️ Post ID {post_id_str } missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).") primary_sort_val = "0000-00-00T00:00:00"
secondary_sort_val =0 if published_date_str:
try : primary_sort_val = published_date_str
secondary_sort_val =int (post_id_str ) elif added_date_str:
except ValueError : logger(f" ⚠️ Post ID {post_id_str} missing 'published' date, using 'added' date '{added_date_str}' for primary sorting.")
logger (f" ⚠️ Post ID '{post_id_str }' is not a valid integer for secondary sorting, using 0.") primary_sort_val = added_date_str
return (primary_sort_val ,secondary_sort_val ) else:
all_posts_for_manga_mode .sort (key =sort_key_tuple ) logger(f" ⚠️ Post ID {post_id_str} missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).")
for i in range (0 ,len (all_posts_for_manga_mode ),page_size ): secondary_sort_val = 0
if cancellation_event and cancellation_event .is_set (): try:
logger (" Manga mode post yielding cancelled.") secondary_sort_val = int(post_id_str)
except ValueError:
logger(f" ⚠️ Post ID '{post_id_str}' is not a valid integer for secondary sorting, using 0.")
return (primary_sort_val, secondary_sort_val)
all_posts_for_manga_mode.sort(key=sort_key_tuple)
for i in range(0, len(all_posts_for_manga_mode), page_size):
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post yielding cancelled.")
break break
yield all_posts_for_manga_mode [i :i +page_size ] yield all_posts_for_manga_mode[i:i + page_size]
return return
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check == STYLE_DATE_POST_TITLE):
logger(f" Manga Mode (Style: {STYLE_DATE_POST_TITLE}): Processing posts in default API order (newest first).")
current_page_num = 1
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check ==STYLE_DATE_POST_TITLE ): current_offset = 0
logger (f" Manga Mode (Style: {STYLE_DATE_POST_TITLE }): Processing posts in default API order (newest first).") processed_target_post_flag = False
if start_page and start_page > 1 and not target_post_id:
current_page_num =1 current_offset = (start_page - 1) * page_size
current_offset =0 current_page_num = start_page
processed_target_post_flag =False logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
if start_page and start_page >1 and not target_post_id : while True:
current_offset =(start_page -1 )*page_size if pause_event and pause_event.is_set():
current_page_num =start_page logger(" Post fetching loop paused...")
logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).") while pause_event.is_set():
while True : if cancellation_event and cancellation_event.is_set():
if pause_event and pause_event .is_set (): logger(" Post fetching loop cancelled while paused.")
logger (" Post fetching loop paused...")
while pause_event .is_set ():
if cancellation_event and cancellation_event .is_set ():
logger (" Post fetching loop cancelled while paused.")
break break
time .sleep (0.5 ) time.sleep(0.5)
if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.") if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
if cancellation_event and cancellation_event .is_set (): if cancellation_event and cancellation_event.is_set():
logger (" Post fetching loop cancelled.") logger(" Post fetching loop cancelled.")
break break
if target_post_id and processed_target_post_flag : if target_post_id and processed_target_post_flag:
break break
if not target_post_id and end_page and current_page_num >end_page : if not target_post_id and end_page and current_page_num > end_page:
logger (f"✅ Reached specified end page ({end_page }) for creator feed. Stopping.") logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
break break
try : try:
posts_batch =fetch_posts_paginated (api_base_url ,headers ,current_offset ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api ) posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance (posts_batch ,list ): if not isinstance(posts_batch, list):
logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).") logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
break break
except RuntimeError as e : except RuntimeError as e:
if "cancelled by user"in str (e ).lower (): if "cancelled by user" in str(e).lower():
logger (f" Pagination stopped due to cancellation: {e }") logger(f" Pagination stopped due to cancellation: {e}")
else : else:
logger (f"{e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).") logger(f"{e}\n Aborting pagination at page {current_page_num} (offset {current_offset}).")
break break
except Exception as e : except Exception as e:
logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }") logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback .print_exc () traceback.print_exc()
break break
if not posts_batch : if processed_post_ids:
if target_post_id and not processed_target_post_flag : original_count = len(posts_batch)
logger (f"❌ Target post {target_post_id } not found after checking all available pages (API returned no more posts at offset {current_offset }).") posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
elif not target_post_id : skipped_count = original_count - len(posts_batch)
if current_page_num ==(start_page or 1 ): if skipped_count > 0:
logger (f"😕 No posts found on the first page checked (page {current_page_num }, offset {current_offset }).") logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
else :
logger (f"✅ Reached end of posts (no more content from API at offset {current_offset }).") if not posts_batch:
if target_post_id and not processed_target_post_flag:
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
elif not target_post_id:
if current_page_num == (start_page or 1):
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
else:
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
break break
if target_post_id and not processed_target_post_flag : if target_post_id and not processed_target_post_flag:
matching_post =next ((p for p in posts_batch if str (p .get ('id'))==str (target_post_id )),None ) matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
if matching_post : if matching_post:
logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).") logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
yield [matching_post ] yield [matching_post]
processed_target_post_flag =True processed_target_post_flag = True
elif not target_post_id : elif not target_post_id:
yield posts_batch yield posts_batch
if processed_target_post_flag : if processed_target_post_flag:
break break
current_offset +=page_size current_offset += page_size
current_page_num +=1 current_page_num += 1
time .sleep (0.6 ) time.sleep(0.6)
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event .is_set ()): if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
logger (f"❌ Target post {target_post_id } could not be found after checking all relevant pages (final check after loop).") logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")

View File

@@ -1,13 +1,9 @@
# --- Standard Library Imports ---
import threading import threading
import time import time
import os import os
import json import json
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future from concurrent.futures import ThreadPoolExecutor, as_completed, Future
# --- Local Application Imports ---
# These imports reflect the new, organized project structure.
from .api_client import download_from_api from .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread from .workers import PostProcessorWorker, DownloadThread
from ..config.constants import ( from ..config.constants import (
@@ -36,8 +32,6 @@ class DownloadManager:
self.progress_queue = progress_queue self.progress_queue = progress_queue
self.thread_pool = None self.thread_pool = None
self.active_futures = [] self.active_futures = []
# --- Session State ---
self.cancellation_event = threading.Event() self.cancellation_event = threading.Event()
self.pause_event = threading.Event() self.pause_event = threading.Event()
self.is_running = False self.is_running = False
@@ -64,8 +58,6 @@ class DownloadManager:
if self.is_running: if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.") self._log("❌ Cannot start a new session: A session is already in progress.")
return return
# --- Reset state for the new session ---
self.is_running = True self.is_running = True
self.cancellation_event.clear() self.cancellation_event.clear()
self.pause_event.clear() self.pause_event.clear()
@@ -75,8 +67,6 @@ class DownloadManager:
self.total_downloads = 0 self.total_downloads = 0
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
# --- Decide execution strategy (multi-threaded vs. single-threaded) ---
is_single_post = bool(config.get('target_post_id_from_initial_url')) is_single_post = bool(config.get('target_post_id_from_initial_url'))
use_multithreading = config.get('use_multithreading', True) use_multithreading = config.get('use_multithreading', True)
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
@@ -84,7 +74,6 @@ class DownloadManager:
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
# Start a separate thread to manage fetching and queuing to the thread pool
fetcher_thread = threading.Thread( fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool, target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data), args=(config, restore_data),
@@ -92,16 +81,11 @@ class DownloadManager:
) )
fetcher_thread.start() fetcher_thread.start()
else: else:
# For single posts or sequential manga mode, use a single worker thread
# which is simpler and ensures order.
self._start_single_threaded_session(config) self._start_single_threaded_session(config)
def _start_single_threaded_session(self, config): def _start_single_threaded_session(self, config):
"""Handles downloads that are best processed by a single worker thread.""" """Handles downloads that are best processed by a single worker thread."""
self._log(" Initializing single-threaded download process...") self._log(" Initializing single-threaded download process...")
# The original DownloadThread is now a pure Python thread, not a QThread.
# We run its `run` method in a standard Python thread.
self.worker_thread = threading.Thread( self.worker_thread = threading.Thread(
target=self._run_single_worker, target=self._run_single_worker,
args=(config,), args=(config,),
@@ -112,7 +96,6 @@ class DownloadManager:
def _run_single_worker(self, config): def _run_single_worker(self, config):
"""Target function for the single-worker thread.""" """Target function for the single-worker thread."""
try: try:
# Pass the queue directly to the worker for it to send updates
worker = DownloadThread(config, self.progress_queue) worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread worker.run() # This is the main blocking call for this thread
except Exception as e: except Exception as e:
@@ -129,9 +112,6 @@ class DownloadManager:
try: try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS) num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
# Fetch posts
# In a real implementation, this would call `api_client.download_from_api`
if restore_data: if restore_data:
all_posts = restore_data['all_posts_data'] all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids']) processed_ids = set(restore_data['processed_post_ids'])
@@ -149,12 +129,9 @@ class DownloadManager:
if not posts_to_process: if not posts_to_process:
self._log("✅ No new posts to process.") self._log("✅ No new posts to process.")
return return
# Submit tasks to the pool
for post_data in posts_to_process: for post_data in posts_to_process:
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
break break
# Each PostProcessorWorker gets the queue to send its own updates
worker = PostProcessorWorker(post_data, config, self.progress_queue) worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process) future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result) future.add_done_callback(self._handle_future_result)
@@ -164,12 +141,10 @@ class DownloadManager:
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}") self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
self._log(traceback.format_exc()) self._log(traceback.format_exc())
finally: finally:
# Wait for all submitted tasks to complete before shutting down
if self.thread_pool: if self.thread_pool:
self.thread_pool.shutdown(wait=True) self.thread_pool.shutdown(wait=True)
self.is_running = False self.is_running = False
self._log("🏁 All processing tasks have completed.") self._log("🏁 All processing tasks have completed.")
# Emit final signal
self.progress_queue.put({ self.progress_queue.put({
'type': 'finished', 'type': 'finished',
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
@@ -178,13 +153,20 @@ class DownloadManager:
def _get_all_posts(self, config): def _get_all_posts(self, config):
"""Helper to fetch all posts using the API client.""" """Helper to fetch all posts using the API client."""
all_posts = [] all_posts = []
# This generator yields batches of posts
post_generator = download_from_api( post_generator = download_from_api(
api_url_input=config['api_url'], api_url_input=config['api_url'],
logger=self._log, logger=self._log,
# ... pass other relevant config keys ... start_page=config.get('start_page'),
end_page=config.get('end_page'),
manga_mode=config.get('manga_mode_active', False),
cancellation_event=self.cancellation_event, cancellation_event=self.cancellation_event,
pause_event=self.pause_event pause_event=self.pause_event,
use_cookie=config.get('use_cookie', False),
cookie_text=config.get('cookie_text', ''),
selected_cookie_file=config.get('selected_cookie_file'),
app_base_dir=config.get('app_base_dir'),
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
processed_post_ids=config.get('processed_post_ids', [])
) )
for batch in post_generator: for batch in post_generator:
all_posts.extend(batch) all_posts.extend(batch)
@@ -203,14 +185,11 @@ class DownloadManager:
self.total_skips += 1 self.total_skips += 1
else: else:
result = future.result() result = future.result()
# Unpack result tuple from the worker
(dl_count, skip_count, kept_originals, (dl_count, skip_count, kept_originals,
retryable, permanent, history) = result retryable, permanent, history) = result
self.total_downloads += dl_count self.total_downloads += dl_count
self.total_skips += skip_count self.total_skips += skip_count
self.all_kept_original_filenames.extend(kept_originals) self.all_kept_original_filenames.extend(kept_originals)
# Queue up results for UI to handle
if retryable: if retryable:
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)}) self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
if permanent: if permanent:
@@ -221,8 +200,6 @@ class DownloadManager:
except Exception as e: except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}") self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped self.total_skips += 1 # Count errored posts as skipped
# Update overall progress
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def cancel_session(self): def cancel_session(self):
@@ -231,11 +208,7 @@ class DownloadManager:
return return
self._log("⚠️ Cancellation requested by user...") self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set() self.cancellation_event.set()
# For single thread mode, the worker checks the event
# For multi-thread mode, shut down the pool
if self.thread_pool: if self.thread_pool:
# Don't wait, just cancel pending futures and let the fetcher thread exit
self.thread_pool.shutdown(wait=False, cancel_futures=True) self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.is_running = False self.is_running = False

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,18 +1,10 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout QPushButton, QVBoxLayout
) )
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
# --- Constants for Dialog Choices ---
# These were moved from main.py to be self-contained within this module's context.
CONFIRM_ADD_ALL_ACCEPTED = 1 CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
self.parent_app = parent_app self.parent_app = parent_app
self.setModal(True) self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list self.new_filter_objects_list = new_filter_objects_list
# Default choice if the dialog is closed without a button press
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0 scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350 base_min_w, base_min_h = 480, 350
scaled_min_w = int(base_min_w * scale_factor) scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
self.names_list_widget = QListWidget() self.names_list_widget = QListWidget()
self._populate_list() self._populate_list()
main_layout.addWidget(self.names_list_widget) main_layout.addWidget(self.names_list_widget)
# --- Selection Buttons ---
selection_buttons_layout = QHBoxLayout() selection_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items) self.select_all_button.clicked.connect(self._select_all_items)
@@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog):
selection_buttons_layout.addWidget(self.deselect_all_button) selection_buttons_layout.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch() selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout) main_layout.addLayout(selection_buttons_layout)
# --- Action Buttons ---
buttons_layout = QHBoxLayout() buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton() self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected) self.add_selected_button.clicked.connect(self._accept_add_selected)
@@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog):
sensible default if no items are selected but the "Add" button is clicked. sensible default if no items are selected but the "Add" button is clicked.
""" """
super().exec_() super().exec_()
# If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
if isinstance(self.user_choice, list) and not self.user_choice: if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice return self.user_choice

View File

@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class CookieHelpDialog(QDialog): class CookieHelpDialog(QDialog):
""" """

View File

@@ -1,27 +1,18 @@
# --- Standard Library Imports ---
from collections import defaultdict from collections import defaultdict
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
) )
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class DownloadExtractedLinksDialog(QDialog): class DownloadExtractedLinksDialog(QDialog):
""" """
A dialog to select and initiate the download for extracted, supported links A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox. from external cloud services like Mega, Google Drive, and Dropbox.
""" """
# Signal emitted with a list of selected link information dictionaries
download_requested = pyqtSignal(list) download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None): def __init__(self, links_data, parent_app, parent=None):
@@ -36,29 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.links_data = links_data self.links_data = links_data
self.parent_app = parent_app self.parent_app = parent_app
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if not app_icon.isNull(): if not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Set window size dynamically based on the parent window's size base_width, base_height = 600, 450
if parent: self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
parent_width = parent.width() self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
parent_height = parent.height()
# Use a scaling factor for different screen resolutions
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 500, 400
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
@@ -76,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection) self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list() self._populate_list()
layout.addWidget(self.links_list_widget) layout.addWidget(self.links_list_widget)
# --- Control Buttons ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.select_all_button = QPushButton() self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked)) self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
@@ -108,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog):
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower()) sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles: for post_title_key in sorted_post_titles:
# Add a non-selectable header for each post
header_item = QListWidgetItem(f"{post_title_key}") header_item = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags) header_item.setFlags(Qt.NoItemFlags)
font = header_item.font() font = header_item.font()
@@ -116,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
font.setPointSize(font.pointSize() + 1) font.setPointSize(font.pointSize() + 1)
header_item.setFont(font) header_item.setFont(font)
self.links_list_widget.addItem(header_item) self.links_list_widget.addItem(header_item)
# Add checkable items for each link within that post
for link_info_data in grouped_links[post_title_key]: for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper() platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})" display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
@@ -144,16 +114,16 @@ class DownloadExtractedLinksDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
is_dark_theme = self.parent() and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark" is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
if is_dark_theme and hasattr(self.parent_app, 'get_dark_theme'): if is_dark_theme:
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
# Set header text color based on theme else:
self.setStyleSheet("")
header_color = Qt.cyan if is_dark_theme else Qt.blue header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()): for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i) item = self.links_list_widget.item(i)
# Headers are not checkable
if not item.flags() & Qt.ItemIsUserCheckable: if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color) item.setForeground(header_color)

View File

@@ -1,18 +1,15 @@
# --- Standard Library Imports ---
import os import os
import time import time
import json import json
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea, QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox, QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox QFileDialog, QMessageBox
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class DownloadHistoryDialog (QDialog ): class DownloadHistoryDialog (QDialog ):
@@ -23,18 +20,15 @@ class DownloadHistoryDialog (QDialog ):
self .last_3_downloaded_entries =last_3_downloaded_entries self .last_3_downloaded_entries =last_3_downloaded_entries
self .first_processed_entries =first_processed_entries self .first_processed_entries =first_processed_entries
self .setModal (True ) self .setModal (True )
self._apply_theme()
# Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available
creator_name_cache = getattr(parent_app, 'creator_name_cache', None) creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
if creator_name_cache: if creator_name_cache:
# Patch left pane (files)
for entry in self.last_3_downloaded_entries: for entry in self.last_3_downloaded_entries:
if not entry.get('creator_display_name'): if not entry.get('creator_display_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', '')) user_id = str(entry.get('user_id', ''))
key = (service, user_id) key = (service, user_id)
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series')) entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
# Patch right pane (posts)
for entry in self.first_processed_entries: for entry in self.first_processed_entries:
if not entry.get('creator_name'): if not entry.get('creator_name'):
service = entry.get('service', '').lower() service = entry.get('service', '').lower()
@@ -158,6 +152,14 @@ class DownloadHistoryDialog (QDialog ):
return get_translation (self .parent_app .current_selected_language ,key ,default_text ) return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text return default_text
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))
else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _save_history_to_txt (self ): def _save_history_to_txt (self ):
if not self .last_3_downloaded_entries and not self .first_processed_entries : if not self .last_3_downloaded_entries and not self .first_processed_entries :
QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"), QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"),

View File

@@ -21,6 +21,7 @@ from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...core.api_client import download_from_api from ...core.api_client import download_from_api
from ...utils.network_utils import extract_post_info, prepare_cookies_for_request from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
from ...utils.resolution import get_dark_theme
class PostsFetcherThread (QThread ): class PostsFetcherThread (QThread ):
@@ -129,6 +130,7 @@ class PostsFetcherThread (QThread ):
self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators.")) self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators."))
self .finished_signal .emit () self .finished_signal .emit ()
class EmptyPopupDialog (QDialog ): class EmptyPopupDialog (QDialog ):
"""A simple empty popup dialog.""" """A simple empty popup dialog."""
SCOPE_CHARACTERS ="Characters" SCOPE_CHARACTERS ="Characters"
@@ -138,12 +140,11 @@ class EmptyPopupDialog (QDialog ):
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ): def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
super ().__init__ (parent ) super ().__init__ (parent )
self .setMinimumSize (400 ,300 ) self.parent_app = parent_app_ref
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 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.current_scope_mode = self.SCOPE_CREATORS
self .app_base_dir =app_base_dir self .app_base_dir =app_base_dir
@@ -289,9 +290,14 @@ class EmptyPopupDialog (QDialog ):
self ._retranslate_ui () self ._retranslate_ui ()
if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark": if self.parent_app and self.parent_app.current_theme == "dark":
self .setStyleSheet (self .parent_app .get_dark_theme ()) # Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor )) self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor ))

View File

@@ -10,7 +10,7 @@ from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
# Corrected Import: The filename uses PascalCase. # Corrected Import: The filename uses PascalCase.
from .ExportOptionsDialog import ExportOptionsDialog from .ExportOptionsDialog import ExportOptionsDialog
from ...utils.resolution import get_dark_theme
class ErrorFilesDialog(QDialog): class ErrorFilesDialog(QDialog):
""" """
@@ -42,13 +42,15 @@ class ErrorFilesDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically # --- START OF FIX ---
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 # Get the user-defined scale factor from the parent application.
scale_factor = screen_height / 1080.0 scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_min_w, base_min_h = 500, 300
scaled_min_w = int(base_min_w * scale_factor) # Define base dimensions and apply the correct scale factor.
scaled_min_h = int(base_min_h * scale_factor) base_width, base_height = 550, 400
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -132,9 +134,14 @@ class ErrorFilesDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
if hasattr(self.parent_app, 'get_dark_theme'): # Get the scale factor from the parent app
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
def _select_all_items(self): def _select_all_items(self):
"""Checks all items in the list.""" """Checks all items in the list."""

View File

@@ -10,7 +10,7 @@ from PyQt5.QtWidgets import (
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan. # get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class ExportOptionsDialog(QDialog): class ExportOptionsDialog(QDialog):
""" """

View File

@@ -16,7 +16,7 @@ from ...i18n.translator import get_translation
from ..assets import get_app_icon_object from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request from ...utils.network_utils import prepare_cookies_for_request
from .CookieHelpDialog import CookieHelpDialog from .CookieHelpDialog import CookieHelpDialog
from ...utils.resolution import get_dark_theme
class FavoriteArtistsDialog (QDialog ): class FavoriteArtistsDialog (QDialog ):
"""Dialog to display and select favorite artists.""" """Dialog to display and select favorite artists."""

View File

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

View File

@@ -1,170 +1,245 @@
# --- Standard Library Imports --- # --- Standard Library Imports ---
import os import os
import json
# --- PyQt5 Imports --- # --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox QGroupBox, QComboBox, QMessageBox, QGridLayout
) )
# --- Local Application Imports --- # --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...config.constants import ( 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): class FutureSettingsDialog(QDialog):
""" """
A dialog for managing application-wide settings like theme, language, 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): 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) super().__init__(parent)
self.parent_app = parent_app_ref self.parent_app = parent_app_ref
self.setModal(True) self.setModal(True)
# --- Basic Window Setup ---
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768 scale_factor = screen_height / 800.0
scale_factor = screen_height / 768.0 base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
base_min_w, base_min_h = 380, 250
scaled_min_w = int(base_min_w * scale_factor) scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
# --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
def _init_ui(self): def _init_ui(self):
"""Initializes all UI components and layouts for the dialog.""" """Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
# --- Appearance Settings --- # --- Group 1: Interface Settings ---
self.appearance_group_box = QGroupBox() self.interface_group_box = QGroupBox()
appearance_layout = QVBoxLayout(self.appearance_group_box) interface_layout = QGridLayout(self.interface_group_box)
# Theme
self.theme_label = QLabel()
self.theme_toggle_button = QPushButton() self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme) self.theme_toggle_button.clicked.connect(self._toggle_theme)
appearance_layout.addWidget(self.theme_toggle_button) interface_layout.addWidget(self.theme_label, 0, 0)
layout.addWidget(self.appearance_group_box) interface_layout.addWidget(self.theme_toggle_button, 0, 1)
# --- Language Settings --- # UI Scale
self.language_group_box = QGroupBox() self.ui_scale_label = QLabel()
language_group_layout = QVBoxLayout(self.language_group_box) self.ui_scale_combo_box = QComboBox()
self.language_selection_layout = QHBoxLayout() 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_label = QLabel()
self.language_selection_layout.addWidget(self.language_label)
self.language_combo_box = QComboBox() self.language_combo_box = QComboBox()
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed) self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
self.language_selection_layout.addWidget(self.language_combo_box, 1) interface_layout.addWidget(self.language_label, 2, 0)
language_group_layout.addLayout(self.language_selection_layout) interface_layout.addWidget(self.language_combo_box, 2, 1)
layout.addWidget(self.language_group_box)
# --- Download Settings --- main_layout.addWidget(self.interface_group_box)
self.download_settings_group_box = QGroupBox()
download_settings_layout = QVBoxLayout(self.download_settings_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 = QPushButton()
self.save_path_button.clicked.connect(self._save_download_path) self.save_path_button.clicked.connect(self._save_download_path)
download_settings_layout.addWidget(self.save_path_button) download_window_layout.addWidget(self.default_path_label, 1, 0)
layout.addWidget(self.download_settings_group_box) 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 --- # --- OK Button ---
self.ok_button = QPushButton() self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept) 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=""): 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: if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text) return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text return default_text
def _retranslate_ui(self): def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("settings_dialog_title", "Settings")) 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.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.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")) 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): def _apply_theme(self):
"""Applies the current theme from the parent application.""" if self.parent_app and self.parent_app.current_theme == "dark":
if self.parent_app.current_theme == "dark": scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(self.parent_app.get_dark_theme()) self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("") self.setStyleSheet("")
def _update_theme_toggle_button_text(self): 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": 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.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: else:
self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode")) 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): def _toggle_theme(self):
"""Toggles the application theme and updates the UI."""
new_theme = "light" if self.parent_app.current_theme == "dark" else "dark" new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
self.parent_app.apply_theme(new_theme) self.parent_app.settings.setValue(THEME_KEY, new_theme)
self._retranslate_ui() self.parent_app.settings.sync()
self.parent_app.current_theme = new_theme
self._apply_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): def _populate_language_combo_box(self):
"""Populates the language dropdown with available languages."""
self.language_combo_box.blockSignals(True) self.language_combo_box.blockSignals(True)
self.language_combo_box.clear() self.language_combo_box.clear()
languages = [ languages = [
("en","English"), ("en", "English"), ("ja", "日本語 (Japanese)"), ("fr", "Français (French)"),
("ja","日本語 (Japanese)"), ("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
("fr","Français (French)"), ("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
("de","Deutsch (German)"), ("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
("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: for lang_code, lang_name in languages:
self.language_combo_box.addItem(lang_name, lang_code) 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.setCurrentIndex(self.language_combo_box.count() - 1)
self.language_combo_box.blockSignals(False) self.language_combo_box.blockSignals(False)
def _language_selection_changed(self, index): def _language_selection_changed(self, index):
"""Handles the user selecting a new language."""
selected_lang_code = self.language_combo_box.itemData(index) selected_lang_code = self.language_combo_box.itemData(index)
if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language: 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.setValue(LANGUAGE_KEY, selected_lang_code)
self.parent_app.settings.sync() self.parent_app.settings.sync()
self.parent_app.current_selected_language = selected_lang_code
self._retranslate_ui() self._retranslate_ui()
if hasattr(self.parent_app, '_retranslate_main_ui'):
self.parent_app._retranslate_main_ui()
msg_box = QMessageBox(self) msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information) msg_box.setIcon(QMessageBox.Information)
@@ -180,23 +255,21 @@ class FutureSettingsDialog(QDialog):
self.parent_app._request_restart_application() self.parent_app._request_restart_application()
def _save_download_path(self): 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: if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip() current_path = self.parent_app.dir_input.text().strip()
if current_path: if current_path and os.path.isdir(current_path):
if os.path.isdir(current_path): self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path) self.parent_app.settings.sync()
self.parent_app.settings.sync() QMessageBox.information(self,
QMessageBox.information(self, self._tr("settings_save_path_success_title", "Path Saved"),
self._tr("settings_save_path_success_title", "Path Saved"), self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path)) elif not current_path:
else: QMessageBox.warning(self,
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,
self._tr("settings_save_path_empty_title", "Empty Path"), self._tr("settings_save_path_empty_title", "Empty Path"),
self._tr("settings_save_path_empty_message", "Download location cannot be empty.")) 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: 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.")

View File

@@ -1,34 +1,32 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import QUrl, QSize, Qt from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon, QDesktopServices from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget QStackedWidget, QScrollArea, QFrame, QWidget
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class TourStepWidget(QWidget): class TourStepWidget(QWidget):
""" """
A custom widget representing a single step or page in the feature guide. A custom widget representing a single step or page in the feature guide.
It neatly formats a title and its corresponding content. It neatly formats a title and its corresponding content.
""" """
def __init__(self, title_text, content_text, parent=None): def __init__(self, title_text, content_text, parent=None, scale=1.0):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20) layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10) layout.setSpacing(10)
title_font_size = int(14 * scale)
content_font_size = int(11 * scale)
title_label = QLabel(title_text) title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter) title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;") title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label) layout.addWidget(title_label)
scroll_area = QScrollArea() scroll_area = QScrollArea()
@@ -42,8 +40,8 @@ class TourStepWidget(QWidget):
content_label.setWordWrap(True) content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop) content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText) content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True) # Allow opening links in the content content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label) scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1) layout.addWidget(scroll_area, 1)
@@ -56,27 +54,38 @@ class HelpGuideDialog (QDialog ):
self .steps_data =steps_data self .steps_data =steps_data
self .parent_app =parent_app self .parent_app =parent_app
app_icon =get_app_icon_object () scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
self .setModal (True ) self.setModal(True)
self .setFixedSize (650 ,600 ) self.resize(int(650 * scale), int(600 * scale))
dialog_font_size = int(11 * scale)
current_theme_style ="" current_theme_style = ""
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark": if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
if hasattr (self .parent_app ,'get_dark_theme'): current_theme_style = get_dark_theme(scale)
current_theme_style =self .parent_app .get_dark_theme () else:
current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }}
QPushButton {{
background-color: #E1E1E1;
color: #1E1E1E;
border: 1px solid #ADADAD;
padding: {int(8*scale)}px {int(15*scale)}px;
border-radius: 4px;
min-height: {int(25*scale)}px;
font-size: {dialog_font_size}pt;
}}
QPushButton:hover {{ background-color: #CACACA; }}
QPushButton:pressed {{ background-color: #B0B0B0; }}
"""
self.setStyleSheet(current_theme_style)
self .setStyleSheet (current_theme_style if current_theme_style else """
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
QLabel { color: #E0E0E0; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
QPushButton:hover { background-color: #656565; }
QPushButton:pressed { background-color: #4A4A4A; }
""")
self ._init_ui () self ._init_ui ()
if self .parent_app : if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ()) self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
@@ -97,10 +106,11 @@ class HelpGuideDialog (QDialog ):
main_layout .addWidget (self .stacked_widget ,1 ) main_layout .addWidget (self .stacked_widget ,1 )
self .tour_steps_widgets =[] self .tour_steps_widgets =[]
for title ,content in self .steps_data : scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
step_widget =TourStepWidget (title ,content ) for title, content in self.steps_data:
self .tour_steps_widgets .append (step_widget ) step_widget = TourStepWidget(title, content, scale=scale)
self .stacked_widget .addWidget (step_widget ) self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide")) self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
@@ -115,7 +125,6 @@ class HelpGuideDialog (QDialog ):
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'): if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS assets_base_dir =sys ._MEIPASS
else : else :
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png") github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
@@ -126,7 +135,9 @@ class HelpGuideDialog (QDialog ):
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"") self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"") self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
icon_size =QSize (24 ,24 ) scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size ) self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size ) self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size ) self .Discord_button .setIconSize (icon_size )

View File

@@ -0,0 +1,107 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
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)
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)
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_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)
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)
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

View File

@@ -8,13 +8,12 @@ from PyQt5.QtWidgets import (
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class KnownNamesFilterDialog(QDialog): class KnownNamesFilterDialog(QDialog):
""" """
A dialog to select names from the Known.txt list to add to the main A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads. to reuse their saved names and groups for filtering downloads.
""" """
@@ -38,13 +37,16 @@ class KnownNamesFilterDialog(QDialog):
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
# Set window size dynamically # --- START OF FIX ---
screen_geometry = QApplication.primaryScreen().availableGeometry() # Get the user-defined scale factor from the parent application
# instead of calculating an independent one.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base size and apply the correct scale factor
base_width, base_height = 460, 450 base_width, base_height = 460, 450
scale_factor_h = screen_geometry.height() / 1080.0 self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5)) self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor)) # --- END OF FIX ---
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
# --- Initialize UI and Apply Theming --- # --- Initialize UI and Apply Theming ---
self._init_ui() self._init_ui()
@@ -102,8 +104,14 @@ class KnownNamesFilterDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme()) # Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
def _populate_list_widget(self): def _populate_list_widget(self):
"""Populates the list widget with the known names.""" """Populates the list widget with the known names."""

View File

@@ -2,6 +2,7 @@ from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox
) )
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from ...utils.resolution import get_dark_theme
class MoreOptionsDialog(QDialog): class MoreOptionsDialog(QDialog):
""" """
@@ -12,6 +13,7 @@ class MoreOptionsDialog(QDialog):
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False): def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
super().__init__(parent) super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("More Options") self.setWindowTitle("More Options")
self.setMinimumWidth(350) self.setMinimumWidth(350)
@@ -22,7 +24,7 @@ class MoreOptionsDialog(QDialog):
layout.addWidget(self.description_label) layout.addWidget(self.description_label)
self.radio_button_group = QButtonGroup(self) self.radio_button_group = QButtonGroup(self)
self.radio_content = QRadioButton("Description/Content") self.radio_content = QRadioButton("Description/Content")
self.radio_comments = QRadioButton("Comments") self.radio_comments = QRadioButton("Comments (Not Working)")
self.radio_button_group.addButton(self.radio_content) self.radio_button_group.addButton(self.radio_content)
self.radio_button_group.addButton(self.radio_comments) self.radio_button_group.addButton(self.radio_comments)
layout.addWidget(self.radio_content) layout.addWidget(self.radio_content)
@@ -62,7 +64,7 @@ class MoreOptionsDialog(QDialog):
self.button_box.rejected.connect(self.reject) self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box) layout.addWidget(self.button_box)
self.setLayout(layout) self.setLayout(layout)
self._apply_theme()
def update_single_pdf_checkbox_state(self, text): def update_single_pdf_checkbox_state(self, text):
"""Enable the Single PDF checkbox only if the format is PDF.""" """Enable the Single PDF checkbox only if the format is PDF."""
is_pdf = (text.upper() == "PDF") is_pdf = (text.upper() == "PDF")
@@ -81,3 +83,14 @@ class MoreOptionsDialog(QDialog):
def get_single_pdf_state(self): def get_single_pdf_state(self):
"""Returns the state of the Single PDF checkbox.""" """Returns the state of the Single PDF checkbox."""
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled() return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")

View File

@@ -1,34 +1,33 @@
# SinglePDF.py
import os import os
import re
try: try:
from fpdf import FPDF from fpdf import FPDF
FPDF_AVAILABLE = True FPDF_AVAILABLE = True
except ImportError: except ImportError:
FPDF_AVAILABLE = False FPDF_AVAILABLE = False
def strip_html_tags(text):
if not text:
return ""
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
class PDF(FPDF): class PDF(FPDF):
"""Custom PDF class to handle headers and footers.""" """Custom PDF class to handle headers and footers."""
def header(self): def header(self):
# No header
pass pass
def footer(self): def footer(self):
# Position at 1.5 cm from bottom
self.set_y(-15) self.set_y(-15)
self.set_font('DejaVu', '', 8) if self.font_family:
# Page number self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print): def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
""" """
Creates a single PDF from a list of post titles and content. Creates a single, continuous PDF, correctly formatting both descriptions and comments.
Args:
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
output_filename (str): The full path for the output PDF file.
font_path (str): Path to the DejaVuSans.ttf font file.
logger (function, optional): A function to log progress and errors. Defaults to print.
""" """
if not FPDF_AVAILABLE: if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2") logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
@@ -39,34 +38,66 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return False return False
pdf = PDF() pdf = PDF()
default_font_family = 'DejaVu'
bold_font_path = ""
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try: try:
if not os.path.exists(font_path): if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
raise RuntimeError("Font file not found.") if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True) pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
except Exception as font_error: except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}") logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
logger(" PDF may not support all characters. Falling back to default Arial font.") default_font_family = 'Arial'
pdf.set_font('Arial', '', 12)
pdf.set_font('Arial', 'B', 16)
logger(f" Starting PDF creation with content from {len(posts_data)} posts...") pdf.add_page()
for post in posts_data: logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
pdf.add_page()
# Post Title
pdf.set_font('DejaVu', 'B', 16)
# vvv THIS LINE IS CORRECTED vvv for i, post in enumerate(posts_data):
# We explicitly set align='L' and remove the incorrect positional arguments. if i > 0:
if 'content' in post:
pdf.add_page()
elif 'comments' in post:
pdf.ln(10)
pdf.cell(0, 0, '', border='T')
pdf.ln(10)
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L') pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
pdf.ln(5) # Add a little space after the title if 'comments' in post and post['comments']:
comments_list = post['comments']
for comment_index, comment in enumerate(comments_list):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
# Post Content pdf.set_font(default_font_family, '', 10)
pdf.set_font('DejaVu', '', 12) pdf.write(8, "Comment by: ")
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content')) if user is not None:
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if comment_index < len(comments_list) - 1:
pdf.ln(3)
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
elif 'content' in post:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
try: try:
pdf.output(output_filename) pdf.output(output_filename)

View File

@@ -0,0 +1,155 @@
# src/ui/dialogs/SupportDialog.py
# --- Standard Library Imports ---
import sys
import os
# --- 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.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(450)
self._init_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
# Main layout
main_layout = QVBoxLayout(self)
main_layout.setSpacing(15)
# Title Label
title_label = QLabel("Thank You for Your Support!")
font = title_label.font()
font.setPointSize(14)
font.setBold(True)
title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
# Informational Text
info_label = QLabel(
"If you find this application useful, please consider supporting its development. "
"Your contribution helps cover costs and encourages future updates and features."
)
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(info_label)
# Separator
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
main_layout.addWidget(line)
# --- 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_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_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_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_text_label.setOpenExternalLinks(True)
github_text_label.setFont(link_font)
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)
main_layout.addWidget(self.button_box)
self.setLayout(main_layout)
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")

View File

@@ -1,17 +1,13 @@
# --- Standard Library Imports ---
import os import os
import sys import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
) )
# --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
from ...config.constants import ( from ...config.constants import (
CONFIG_ORGANIZATION_NAME CONFIG_ORGANIZATION_NAME
) )
@@ -57,8 +53,6 @@ class TourDialog(QDialog):
""" """
tour_finished_normally = pyqtSignal() tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal() tour_skipped = pyqtSignal()
# Constants for QSettings
CONFIG_APP_NAME_TOUR = "ApplicationTour" CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19" TOUR_SHOWN_KEY = "neverShowTourAgainV19"
@@ -97,8 +91,6 @@ class TourDialog(QDialog):
self.stacked_widget = QStackedWidget() self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1) main_layout.addWidget(self.stacked_widget, 1)
# Load content for each step
steps_content = [ steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"), ("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"), ("tour_dialog_step2_title", "tour_dialog_step2_content"),
@@ -119,8 +111,6 @@ class TourDialog(QDialog):
self.stacked_widget.addWidget(step_widget) self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!")) self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
# --- Bottom Controls ---
bottom_controls_layout = QVBoxLayout() bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15) bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12) bottom_controls_layout.setSpacing(12)
@@ -150,8 +140,9 @@ class TourDialog(QDialog):
def _apply_theme(self): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme()) scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else: else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }") self.setStyleSheet("QDialog { background-color: #f0f0f0; }")

File diff suppressed because it is too large Load Diff

View File

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

580
src/utils/resolution.py Normal file
View File

@@ -0,0 +1,580 @@
# src/ui/utils/resolution.py
# --- Standard Library Imports ---
import os
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QSplitter, QScrollArea, QFrame, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QStackedWidget, QButtonGroup, QRadioButton, QCheckBox,
QListWidget, QTextEdit, QApplication
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIntValidator, QFont # <-- Import QFont
# --- Local Application Imports ---
# Assuming execution from project root
from ..config.constants import *
def setup_ui(main_app):
"""
Initializes and scales the user interface for the DownloaderApp.
Args:
main_app: The instance of the main DownloaderApp.
"""
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)
# --- Use a scroll area for the left panel for consistency ---
left_scroll_area = QScrollArea()
left_scroll_area.setWidgetResizable(True)
left_scroll_area.setFrameShape(QFrame.NoFrame)
left_panel_widget = QWidget()
left_layout = QVBoxLayout(left_panel_widget)
left_scroll_area.setWidget(left_panel_widget)
right_panel_widget = QWidget()
right_layout = QVBoxLayout(right_panel_widget)
left_layout.setContentsMargins(10, 10, 10, 10)
right_layout.setContentsMargins(10, 10, 10, 10)
apply_theme_to_app(main_app, main_app.current_theme, initial_load=True)
# --- URL and Page Range ---
main_app.url_input_widget = QWidget()
url_input_layout = QHBoxLayout(main_app.url_input_widget)
url_input_layout.setContentsMargins(0, 0, 0, 0)
main_app.url_label_widget = QLabel()
url_input_layout.addWidget(main_app.url_label_widget)
main_app.link_input = QLineEdit()
main_app.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765")
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("🎨")
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:"))
main_app.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;")
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(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(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)
main_app.url_placeholder_widget = QWidget()
placeholder_layout = QHBoxLayout(main_app.url_placeholder_widget)
placeholder_layout.setContentsMargins(0, 0, 0, 0)
main_app.fav_mode_active_label = QLabel(main_app._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active..."))
main_app.fav_mode_active_label.setAlignment(Qt.AlignCenter)
placeholder_layout.addWidget(main_app.fav_mode_active_label)
main_app.url_or_placeholder_stack = QStackedWidget()
main_app.url_or_placeholder_stack.addWidget(main_app.url_input_widget)
main_app.url_or_placeholder_stack.addWidget(main_app.url_placeholder_widget)
left_layout.addWidget(main_app.url_or_placeholder_stack)
# --- Download Location ---
main_app.download_location_label_widget = QLabel()
left_layout.addWidget(main_app.download_location_label_widget)
dir_layout = QHBoxLayout()
main_app.dir_input = QLineEdit()
main_app.dir_input.setPlaceholderText("Select folder where downloads will be saved")
main_app.dir_button = QPushButton("Browse...")
main_app.dir_button.clicked.connect(main_app.browse_directory)
dir_layout.addWidget(main_app.dir_input, 1)
dir_layout.addWidget(main_app.dir_button)
left_layout.addLayout(dir_layout)
# --- Filters and Custom Folder Container ---
main_app.filters_and_custom_folder_container_widget = QWidget()
filters_and_custom_folder_layout = QHBoxLayout(main_app.filters_and_custom_folder_container_widget)
filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0)
filters_and_custom_folder_layout.setSpacing(10)
main_app.character_filter_widget = QWidget()
character_filter_v_layout = QVBoxLayout(main_app.character_filter_widget)
character_filter_v_layout.setContentsMargins(0, 0, 0, 0)
character_filter_v_layout.setSpacing(2)
main_app.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):")
character_filter_v_layout.addWidget(main_app.character_label)
char_input_and_button_layout = QHBoxLayout()
char_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
char_input_and_button_layout.setSpacing(10)
main_app.character_input = QLineEdit()
main_app.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)")
char_input_and_button_layout.addWidget(main_app.character_input, 3)
main_app.char_filter_scope_toggle_button = QPushButton()
main_app._update_char_filter_scope_button_text()
char_input_and_button_layout.addWidget(main_app.char_filter_scope_toggle_button, 1)
character_filter_v_layout.addLayout(char_input_and_button_layout)
# --- Custom Folder Widget Definition ---
main_app.custom_folder_widget = QWidget()
custom_folder_v_layout = QVBoxLayout(main_app.custom_folder_widget)
custom_folder_v_layout.setContentsMargins(0, 0, 0, 0)
custom_folder_v_layout.setSpacing(2)
main_app.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):")
main_app.custom_folder_input = QLineEdit()
main_app.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder")
custom_folder_v_layout.addWidget(main_app.custom_folder_label)
custom_folder_v_layout.addWidget(main_app.custom_folder_input)
main_app.custom_folder_widget.setVisible(False)
filters_and_custom_folder_layout.addWidget(main_app.character_filter_widget, 1)
filters_and_custom_folder_layout.addWidget(main_app.custom_folder_widget, 1)
left_layout.addWidget(main_app.filters_and_custom_folder_container_widget)
# --- Word Manipulation Container ---
word_manipulation_container_widget = QWidget()
word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget)
word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0)
word_manipulation_outer_layout.setSpacing(15)
skip_words_widget = QWidget()
skip_words_vertical_layout = QVBoxLayout(skip_words_widget)
skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0)
skip_words_vertical_layout.setSpacing(2)
main_app.skip_words_label_widget = QLabel()
skip_words_vertical_layout.addWidget(main_app.skip_words_label_widget)
skip_input_and_button_layout = QHBoxLayout()
skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
skip_input_and_button_layout.setSpacing(10)
main_app.skip_words_input = QLineEdit()
main_app.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
skip_input_and_button_layout.addWidget(main_app.skip_words_input, 1)
main_app.skip_scope_toggle_button = QPushButton()
main_app._update_skip_scope_button_text()
skip_input_and_button_layout.addWidget(main_app.skip_scope_toggle_button, 0)
skip_words_vertical_layout.addLayout(skip_input_and_button_layout)
word_manipulation_outer_layout.addWidget(skip_words_widget, 7)
remove_words_widget = QWidget()
remove_words_vertical_layout = QVBoxLayout(remove_words_widget)
remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0)
remove_words_vertical_layout.setSpacing(2)
main_app.remove_from_filename_label_widget = QLabel()
remove_words_vertical_layout.addWidget(main_app.remove_from_filename_label_widget)
main_app.remove_from_filename_input = QLineEdit()
main_app.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD")
remove_words_vertical_layout.addWidget(main_app.remove_from_filename_input)
word_manipulation_outer_layout.addWidget(remove_words_widget, 3)
left_layout.addWidget(word_manipulation_container_widget)
# --- File Filter Layout ---
file_filter_layout = QVBoxLayout()
file_filter_layout.setContentsMargins(0, 10, 0, 0)
file_filter_layout.addWidget(QLabel("Filter Files:"))
radio_button_layout = QHBoxLayout()
radio_button_layout.setSpacing(10)
main_app.radio_group = QButtonGroup(main_app)
main_app.radio_all = QRadioButton("All")
main_app.radio_images = QRadioButton("Images/GIFs")
main_app.radio_videos = QRadioButton("Videos")
main_app.radio_only_archives = QRadioButton("📦 Only Archives")
main_app.radio_only_audio = QRadioButton("🎧 Only Audio")
main_app.radio_only_links = QRadioButton("🔗 Only Links")
main_app.radio_more = QRadioButton("More")
main_app.radio_all.setChecked(True)
for btn in [main_app.radio_all, main_app.radio_images, main_app.radio_videos, main_app.radio_only_archives, main_app.radio_only_audio, main_app.radio_only_links, main_app.radio_more]:
main_app.radio_group.addButton(btn)
radio_button_layout.addWidget(btn)
main_app.favorite_mode_checkbox = QCheckBox()
main_app.favorite_mode_checkbox.setChecked(False)
radio_button_layout.addWidget(main_app.favorite_mode_checkbox)
radio_button_layout.addStretch(1)
file_filter_layout.addLayout(radio_button_layout)
left_layout.addLayout(file_filter_layout)
# --- Checkboxes Group ---
checkboxes_group_layout = QVBoxLayout()
checkboxes_group_layout.setSpacing(10)
row1_layout = QHBoxLayout()
row1_layout.setSpacing(10)
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.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")
main_app.scan_content_images_checkbox.setChecked(main_app.scan_content_images_setting)
row1_layout.addWidget(main_app.scan_content_images_checkbox)
main_app.compress_images_checkbox = QCheckBox("Compress to WebP")
main_app.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).")
row1_layout.addWidget(main_app.compress_images_checkbox)
main_app.keep_duplicates_checkbox = QCheckBox("Keep Duplicates")
main_app.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.")
row1_layout.addWidget(main_app.keep_duplicates_checkbox)
row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(row1_layout)
# --- Advanced Settings ---
advanced_settings_label = QLabel("⚙️ Advanced Settings:")
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 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)
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit()
main_app.cookie_text_input.setPlaceholderText("if no Select cookies.txt)")
main_app.cookie_text_input.setText(main_app.cookie_text_setting)
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(int(80 * scale))
advanced_row1_layout.addWidget(main_app.cookie_browse_button)
advanced_row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row1_layout)
advanced_row2_layout = QHBoxLayout()
advanced_row2_layout.setSpacing(10)
multithreading_layout = QHBoxLayout()
multithreading_layout.setContentsMargins(0, 0, 0, 0)
main_app.use_multithreading_checkbox = QCheckBox("Use Multithreading")
main_app.use_multithreading_checkbox.setChecked(True)
multithreading_layout.addWidget(main_app.use_multithreading_checkbox)
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(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)
main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
advanced_row2_layout.addWidget(main_app.external_links_checkbox)
main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout)
left_layout.addLayout(checkboxes_group_layout)
# --- Action Buttons ---
main_app.standard_action_buttons_widget = QWidget()
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
btn_layout.setContentsMargins(0, 10, 0, 0)
btn_layout.setSpacing(10)
main_app.download_btn = QPushButton("⬇️ Start Download")
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)
main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action)
main_app.cancel_btn = QPushButton("❌ Cancel & Reset UI")
main_app.cancel_btn.setEnabled(False)
main_app.cancel_btn.clicked.connect(main_app.cancel_download_button_action)
main_app.error_btn = QPushButton("Error")
main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.")
main_app.error_btn.setEnabled(True)
btn_layout.addWidget(main_app.download_btn)
btn_layout.addWidget(main_app.pause_btn)
btn_layout.addWidget(main_app.cancel_btn)
btn_layout.addWidget(main_app.error_btn)
main_app.favorite_action_buttons_widget = QWidget()
favorite_buttons_layout = QHBoxLayout(main_app.favorite_action_buttons_widget)
main_app.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists")
main_app.favorite_mode_posts_button = QPushButton("📄 Favorite Posts")
main_app.favorite_scope_toggle_button = QPushButton()
favorite_buttons_layout.addWidget(main_app.favorite_mode_artists_button)
favorite_buttons_layout.addWidget(main_app.favorite_mode_posts_button)
favorite_buttons_layout.addWidget(main_app.favorite_scope_toggle_button)
main_app.bottom_action_buttons_stack = QStackedWidget()
main_app.bottom_action_buttons_stack.addWidget(main_app.standard_action_buttons_widget)
main_app.bottom_action_buttons_stack.addWidget(main_app.favorite_action_buttons_widget)
left_layout.addWidget(main_app.bottom_action_buttons_stack)
left_layout.addSpacing(10)
# --- Known Names Layout ---
known_chars_label_layout = QHBoxLayout()
known_chars_label_layout.setSpacing(10)
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(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...")
known_chars_label_layout.addWidget(main_app.character_search_input, 1)
left_layout.addLayout(known_chars_label_layout)
main_app.character_list = QListWidget()
main_app.character_list.setSelectionMode(QListWidget.ExtendedSelection)
left_layout.addWidget(main_app.character_list, 1)
char_manage_layout = QHBoxLayout()
char_manage_layout.setSpacing(10)
main_app.new_char_input = QLineEdit()
main_app.new_char_input.setPlaceholderText("Add new show/character name")
main_app.add_char_button = QPushButton(" Add")
main_app.add_to_filter_button = QPushButton("⤵️ Add to Filter")
main_app.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.")
main_app.delete_char_button = QPushButton("🗑️ Delete Selected")
main_app.delete_char_button.setToolTip("Delete the selected name(s)...")
main_app.add_char_button.clicked.connect(main_app._handle_ui_add_new_character)
main_app.new_char_input.returnPressed.connect(main_app.add_char_button.click)
main_app.delete_char_button.clicked.connect(main_app.delete_selected_character)
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(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(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(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(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)
left_layout.addLayout(char_manage_layout)
left_layout.addStretch(0)
# --- Right Panel (Logs) ---
right_panel_widget.setLayout(right_layout)
log_title_layout = QHBoxLayout()
main_app.progress_log_label = QLabel("📜 Progress Log:")
log_title_layout.addWidget(main_app.progress_log_label)
log_title_layout.addStretch(1)
main_app.link_search_input = QLineEdit()
main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False)
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(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(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()
main_app.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames")
main_app.manga_date_prefix_input.setVisible(False)
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(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(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(int(80 * scale))
log_title_layout.addWidget(main_app.reset_button)
right_layout.addLayout(log_title_layout)
main_app.log_splitter = QSplitter(Qt.Vertical)
main_app.log_view_stack = QStackedWidget()
main_app.main_log_output = QTextEdit()
main_app.main_log_output.setReadOnly(True)
main_app.main_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.log_view_stack.addWidget(main_app.main_log_output)
main_app.missed_character_log_output = QTextEdit()
main_app.missed_character_log_output.setReadOnly(True)
main_app.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.log_view_stack.addWidget(main_app.missed_character_log_output)
main_app.external_log_output = QTextEdit()
main_app.external_log_output.setReadOnly(True)
main_app.external_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.external_log_output.hide()
main_app.log_splitter.addWidget(main_app.log_view_stack)
main_app.log_splitter.addWidget(main_app.external_log_output)
main_app.log_splitter.setSizes([main_app.height(), 0])
right_layout.addWidget(main_app.log_splitter, 1)
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(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(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(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)
main_app.progress_label = QLabel("Progress: Idle")
main_app.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;")
right_layout.addWidget(main_app.progress_label)
main_app.file_progress_label = QLabel("")
main_app.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.")
main_app.file_progress_label.setWordWrap(True)
main_app.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(main_app.file_progress_label)
# --- Final Assembly ---
main_app.main_splitter.addWidget(left_scroll_area)
main_app.main_splitter.addWidget(right_panel_widget)
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:
# Default for lower resolutions, giving more space to controls (left).
main_app.main_splitter.setStretchFactor(0, 7)
main_app.main_splitter.setStretchFactor(1, 3)
top_level_layout = QHBoxLayout(main_app)
top_level_layout.setContentsMargins(0, 0, 0, 0)
top_level_layout.addWidget(main_app.main_splitter)
# --- Initial UI State Updates ---
main_app.update_ui_for_subfolders(main_app.use_subfolders_checkbox.isChecked())
main_app.update_external_links_setting(main_app.external_links_checkbox.isChecked())
main_app.update_multithreading_label(main_app.thread_count_input.text())
main_app.update_page_range_enabled_state()
if main_app.manga_mode_checkbox:
main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked())
if hasattr(main_app, 'link_input'):
main_app.link_input.textChanged.connect(lambda: main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked() if main_app.manga_mode_checkbox else False))
main_app._load_creator_name_cache_from_json()
main_app.load_known_names_from_util()
main_app._update_cookie_input_visibility(main_app.use_cookie_checkbox.isChecked() if hasattr(main_app, 'use_cookie_checkbox') else False)
main_app._handle_multithreading_toggle(main_app.use_multithreading_checkbox.isChecked())
if hasattr(main_app, 'radio_group') and main_app.radio_group.checkedButton():
main_app._handle_filter_mode_change(main_app.radio_group.checkedButton(), True)
main_app.radio_group.buttonToggled.connect(main_app._handle_more_options_toggled)
main_app._update_manga_filename_style_button_text()
main_app._update_skip_scope_button_text()
main_app._update_char_filter_scope_button_text()
main_app._update_multithreading_for_date_mode()
if hasattr(main_app, 'download_thumbnails_checkbox'):
main_app._handle_thumbnail_mode_change(main_app.download_thumbnails_checkbox.isChecked())
if hasattr(main_app, 'favorite_mode_checkbox'):
main_app._handle_favorite_mode_toggle(False)
def get_dark_theme(scale=1):
"""
Generates the stylesheet for the dark theme, scaled by the given factor.
"""
# Adjust base font size for better readability
font_size_base = 9.5
font_size_small_base = 8.5
# Apply scaling
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, 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; 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;
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.
"""
main_app.current_theme = theme_name
if not initial_load:
main_app.settings.setValue(THEME_KEY, theme_name)
main_app.settings.sync()
if theme_name == "dark":
scale = getattr(main_app, 'scale_factor', 1)
main_app.setStyleSheet(get_dark_theme(scale))
if not initial_load:
main_app.log_signal.emit("🎨 Switched to Dark Mode.")
else:
main_app.setStyleSheet("")
if not initial_load:
main_app.log_signal.emit("🎨 Switched to Light Mode.")
main_app.update()