mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33133eb275 | ||
|
|
3935cbeea4 | ||
|
|
8ba2a572fa | ||
|
|
8db40f03b6 | ||
|
|
742fe7685c | ||
|
|
e085d9a134 | ||
|
|
1cd03731c0 | ||
|
|
0bc8d7c692 | ||
|
|
3a9009e76e | ||
|
|
9a28e922b4 | ||
|
|
923a0ff61e | ||
|
|
e891a2a845 | ||
|
|
778b0219e2 | ||
|
|
3fc08d9ea7 | ||
|
|
af6a6add57 | ||
|
|
7737d32ef9 | ||
|
|
c08cbb6490 | ||
|
|
92a2e91624 | ||
|
|
11ea511a9d | ||
|
|
8abdb49ed8 | ||
|
|
0873dd1ce0 | ||
|
|
df5fbc1f73 | ||
|
|
5510f7f0c6 | ||
|
|
2f0593c450 | ||
|
|
e67adb6bdc | ||
|
|
d39081088c | ||
|
|
f303b8b020 | ||
|
|
539e76aa9e | ||
|
|
574d0d66b4 | ||
|
|
9e58a9d574 | ||
|
|
d67de87a11 | ||
|
|
149f217f2f | ||
|
|
874902ad60 | ||
|
|
440cf60d90 | ||
|
|
fb446a1e28 | ||
|
|
cfd869e05a | ||
|
|
b191776f65 | ||
|
|
f41f354737 | ||
|
|
6b57ee099d | ||
|
|
21ecb60cb5 | ||
|
|
ee00019f2e | ||
|
|
d49c739fe4 | ||
|
|
dbdf82a079 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1 +1,3 @@
|
||||
github: [Yuvi9587]
|
||||
ko_fi: yuvi427183
|
||||
buy_me_a_coffee: yuvi9587
|
||||
BIN
Read/bmac.gif
Normal file
BIN
Read/bmac.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 434 KiB |
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
97
data/dejavu-sans/DejaVu Fonts License.txt
Normal file
@@ -0,0 +1,97 @@
|
||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||
|
||||
Bitstream Vera Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||
a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Arev Fonts Copyright
|
||||
------------------------------
|
||||
|
||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the fonts accompanying this license ("Fonts") and
|
||||
associated documentation files (the "Font Software"), to reproduce
|
||||
and distribute the modifications to the Bitstream Vera Font Software,
|
||||
including without limitation the rights to use, copy, merge, publish,
|
||||
distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice
|
||||
shall be included in all copies of one or more of the Font Software
|
||||
typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in
|
||||
particular the designs of glyphs or characters in the Fonts may be
|
||||
modified and additional glyphs or characters may be added to the
|
||||
Fonts, only if the fonts are renamed to names not containing either
|
||||
the words "Tavmjong Bah" or the word "Arev".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts
|
||||
or Font Software that has been modified and is distributed under the
|
||||
"Tavmjong Bah Arev" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but
|
||||
no copy of one or more of the Font Software typefaces may be sold by
|
||||
itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||
be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in this Font Software without prior written authorization
|
||||
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||
. fr.
|
||||
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Bold.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed-Oblique.ttf
Normal file
Binary file not shown.
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
BIN
data/dejavu-sans/DejaVuSansCondensed.ttf
Normal file
Binary file not shown.
@@ -41,6 +41,7 @@ Built with PyQt5, this tool is designed for users who want deep filtering capabi
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
@@ -208,4 +209,9 @@ This project is under the Custom Licence
|
||||
</a>
|
||||
</table>
|
||||
|
||||
👉 See [features.md](features.md) for the full feature list.
|
||||
<p align="center">
|
||||
<a href="https://buymeacoffee.com/yuvi9587">
|
||||
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ THEME_KEY = "currentThemeV2"
|
||||
SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1"
|
||||
LANGUAGE_KEY = "currentLanguageV1"
|
||||
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
|
||||
RESOLUTION_KEY = "window_resolution"
|
||||
UI_SCALE_KEY = "ui_scale_factor"
|
||||
|
||||
# --- UI Constants and Identifiers ---
|
||||
HTML_PREFIX = "<!HTML!>"
|
||||
@@ -70,7 +72,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
|
||||
|
||||
# --- File Type Extensions ---
|
||||
IMAGE_EXTENSIONS = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
@@ -111,3 +113,7 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
|
||||
"fri", "friday", "sat", "saturday", "sun", "sunday"
|
||||
# add more according to need
|
||||
}
|
||||
|
||||
# --- Duplicate Handling Modes ---
|
||||
DUPLICATE_HANDLING_HASH = "hash"
|
||||
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
|
||||
@@ -1,12 +1,10 @@
|
||||
# --- Standard Library Imports ---
|
||||
import time
|
||||
import traceback
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# --- Third-Party Library Imports ---
|
||||
import json # Ensure json is imported
|
||||
import requests
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# (Keep the rest of your imports)
|
||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ..config.constants import (
|
||||
STYLE_DATE_POST_TITLE
|
||||
@@ -15,36 +13,24 @@ from ..config.constants import (
|
||||
|
||||
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||
"""
|
||||
Fetches a single page of posts from the API with retry logic.
|
||||
|
||||
Args:
|
||||
api_url_base (str): The base URL for the user's posts.
|
||||
headers (dict): The request headers.
|
||||
offset (int): The offset for pagination.
|
||||
logger (callable): Function to log messages.
|
||||
cancellation_event (threading.Event): Event to signal cancellation.
|
||||
pause_event (threading.Event): Event to signal pause.
|
||||
cookies_dict (dict): A dictionary of cookies to include in the request.
|
||||
|
||||
Returns:
|
||||
list: A list of post data dictionaries from the API.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the fetch fails after all retries or encounters a non-retryable error.
|
||||
Fetches a single page of posts from the API with robust retry logic.
|
||||
NEW: Requests only essential fields to keep the response size small and reliable.
|
||||
"""
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Fetch cancelled before request.")
|
||||
raise RuntimeError("Fetch operation cancelled by user.")
|
||||
if pause_event and pause_event.is_set():
|
||||
logger(" Post fetching paused...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Post fetching cancelled while paused.")
|
||||
raise RuntimeError("Fetch operation cancelled by user.")
|
||||
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||
time.sleep(0.5)
|
||||
logger(" Post fetching resumed.")
|
||||
|
||||
paginated_url = f'{api_url_base}?o={offset}'
|
||||
# --- 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"
|
||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 5
|
||||
|
||||
@@ -52,22 +38,18 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
raise RuntimeError("Fetch operation cancelled by user during retry loop.")
|
||||
|
||||
log_message = f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})"
|
||||
log_message = f" Fetching post list: {api_url_base}?o={offset} (Page approx. {offset // 50 + 1})"
|
||||
if attempt > 0:
|
||||
log_message += f" (Attempt {attempt + 1}/{max_retries})"
|
||||
logger(log_message)
|
||||
|
||||
try:
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 90), cookies=cookies_dict)
|
||||
# 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.raise_for_status()
|
||||
|
||||
if 'application/json' not in response.headers.get('Content-Type', '').lower():
|
||||
logger(f"⚠️ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}")
|
||||
return []
|
||||
|
||||
return response.json()
|
||||
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
delay = retry_delay * (2 ** attempt)
|
||||
@@ -76,18 +58,46 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
continue
|
||||
else:
|
||||
logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
|
||||
raise RuntimeError(f"Timeout or connection error fetching offset {offset}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
err_msg = f"Error fetching offset {offset}: {e}"
|
||||
if e.response is not None:
|
||||
err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})"
|
||||
raise RuntimeError(err_msg)
|
||||
except ValueError as e: # JSON decode error
|
||||
raise RuntimeError(f"Error decoding JSON from offset {offset}: {e}. Response: {response.text[:200]}")
|
||||
raise RuntimeError(f"Network error fetching offset {offset}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger(f" ❌ Failed to decode JSON on page fetch (Attempt {attempt + 1}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
delay = retry_delay * (2 ** attempt)
|
||||
logger(f" Retrying in {delay} seconds...")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(f"JSONDecodeError fetching offset {offset}")
|
||||
|
||||
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
||||
|
||||
|
||||
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
|
||||
"""
|
||||
--- NEW FUNCTION ---
|
||||
Fetches the full data, including the 'content' field, for a single post.
|
||||
"""
|
||||
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}...")
|
||||
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:
|
||||
response.raise_for_status()
|
||||
response_body = b""
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
response_body += chunk
|
||||
|
||||
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:
|
||||
return full_post_data[0]
|
||||
return full_post_data
|
||||
|
||||
except Exception as e:
|
||||
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||
"""Fetches all comments for a specific post."""
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
@@ -105,217 +115,248 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}")
|
||||
|
||||
def download_from_api (
|
||||
api_url_input ,
|
||||
logger =print ,
|
||||
start_page =None ,
|
||||
end_page =None ,
|
||||
manga_mode =False ,
|
||||
cancellation_event =None ,
|
||||
pause_event =None ,
|
||||
use_cookie =False ,
|
||||
cookie_text ="",
|
||||
selected_cookie_file =None ,
|
||||
app_base_dir =None ,
|
||||
manga_filename_style_for_sort_check =None
|
||||
def download_from_api(
|
||||
api_url_input,
|
||||
logger=print,
|
||||
start_page=None,
|
||||
end_page=None,
|
||||
manga_mode=False,
|
||||
cancellation_event=None,
|
||||
pause_event=None,
|
||||
use_cookie=False,
|
||||
cookie_text="",
|
||||
selected_cookie_file=None,
|
||||
app_base_dir=None,
|
||||
manga_filename_style_for_sort_check=None,
|
||||
processed_post_ids=None # --- ADD THIS ARGUMENT ---
|
||||
):
|
||||
headers ={
|
||||
'User-Agent':'Mozilla/5.0',
|
||||
'Accept':'application/json'
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
service ,user_id ,target_post_id =extract_post_info (api_url_input )
|
||||
# --- ADD THIS BLOCK ---
|
||||
# Ensure processed_post_ids is a set for fast lookups
|
||||
if processed_post_ids is None:
|
||||
processed_post_ids = set()
|
||||
else:
|
||||
processed_post_ids = set(processed_post_ids)
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
logger (" Download_from_api cancelled at start.")
|
||||
service, user_id, target_post_id = extract_post_info(api_url_input)
|
||||
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Download_from_api cancelled at start.")
|
||||
return
|
||||
|
||||
parsed_input_url_for_domain =urlparse (api_url_input )
|
||||
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']):
|
||||
logger (f"⚠️ Unrecognized domain '{api_domain }' from input URL. Defaulting to kemono.su for API calls.")
|
||||
api_domain ="kemono.su"
|
||||
cookies_for_api =None
|
||||
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 )
|
||||
if target_post_id :
|
||||
direct_post_api_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }/post/{target_post_id }"
|
||||
logger (f" Attempting direct fetch for target post: {direct_post_api_url }")
|
||||
try :
|
||||
direct_response =requests .get (direct_post_api_url ,headers =headers ,timeout =(10 ,30 ),cookies =cookies_for_api )
|
||||
direct_response .raise_for_status ()
|
||||
direct_post_data =direct_response .json ()
|
||||
if isinstance (direct_post_data ,list )and direct_post_data :
|
||||
direct_post_data =direct_post_data [0 ]
|
||||
if isinstance (direct_post_data ,dict )and 'post'in direct_post_data and isinstance (direct_post_data ['post'],dict ):
|
||||
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 ]
|
||||
parsed_input_url_for_domain = urlparse(api_url_input)
|
||||
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']):
|
||||
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
|
||||
api_domain = "kemono.su"
|
||||
cookies_for_api = None
|
||||
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)
|
||||
if target_post_id:
|
||||
# --- ADD THIS CHECK FOR RESTORE ---
|
||||
if target_post_id in processed_post_ids:
|
||||
logger(f" Skipping already processed target post ID: {target_post_id}")
|
||||
return
|
||||
# --- END OF ADDITION ---
|
||||
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
|
||||
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
|
||||
try:
|
||||
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
|
||||
direct_response.raise_for_status()
|
||||
direct_post_data = direct_response.json()
|
||||
if isinstance(direct_post_data, list) and direct_post_data:
|
||||
direct_post_data = direct_post_data[0]
|
||||
if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
|
||||
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
|
||||
else :
|
||||
response_type =type (direct_post_data ).__name__
|
||||
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.")
|
||||
except requests .exceptions .RequestException as e :
|
||||
logger (f" ⚠️ Direct fetch failed for post {target_post_id }: {e }. Falling back to pagination.")
|
||||
except Exception as e :
|
||||
logger (f" ⚠️ Unexpected error during direct fetch for post {target_post_id }: {e }. Falling back to pagination.")
|
||||
if not service or not user_id :
|
||||
logger (f"❌ Invalid URL or could not extract service/user: {api_url_input }")
|
||||
else:
|
||||
response_type = type(direct_post_data).__name__
|
||||
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.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger(f" ⚠️ Direct fetch failed for post {target_post_id}: {e}. Falling back to pagination.")
|
||||
except Exception as e:
|
||||
logger(f" ⚠️ Unexpected error during direct fetch for post {target_post_id}: {e}. Falling back to pagination.")
|
||||
if not service or not user_id:
|
||||
logger(f"❌ Invalid URL or could not extract service/user: {api_url_input}")
|
||||
return
|
||||
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).")
|
||||
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).")
|
||||
|
||||
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 }"
|
||||
page_size =50
|
||||
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...")
|
||||
all_posts_for_manga_mode =[]
|
||||
current_offset_manga =0
|
||||
if start_page and start_page >1 :
|
||||
current_offset_manga =(start_page -1 )*page_size
|
||||
logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).")
|
||||
elif start_page :
|
||||
logger (f" Manga Mode: Starting fetch from page 1 (offset 0).")
|
||||
if end_page :
|
||||
logger (f" Manga Mode: Will fetch up to page {end_page }.")
|
||||
while True :
|
||||
if pause_event and pause_event .is_set ():
|
||||
logger (" Manga mode post fetching paused...")
|
||||
while pause_event .is_set ():
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
logger (" Manga mode post fetching cancelled while paused.")
|
||||
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}"
|
||||
page_size = 50
|
||||
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...")
|
||||
all_posts_for_manga_mode = []
|
||||
current_offset_manga = 0
|
||||
if start_page and start_page > 1:
|
||||
current_offset_manga = (start_page - 1) * page_size
|
||||
logger(f" Manga Mode: Starting fetch from page {start_page} (offset {current_offset_manga}).")
|
||||
elif start_page:
|
||||
logger(f" Manga Mode: Starting fetch from page 1 (offset 0).")
|
||||
if end_page:
|
||||
logger(f" Manga Mode: Will fetch up to page {end_page}.")
|
||||
while True:
|
||||
if pause_event and pause_event.is_set():
|
||||
logger(" Manga mode post fetching paused...")
|
||||
while pause_event.is_set():
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Manga mode post fetching cancelled while paused.")
|
||||
break
|
||||
time .sleep (0.5 )
|
||||
if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.")
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
logger (" Manga mode post fetching cancelled.")
|
||||
time.sleep(0.5)
|
||||
if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Manga mode post fetching cancelled.")
|
||||
break
|
||||
current_page_num_manga =(current_offset_manga //page_size )+1
|
||||
if end_page and current_page_num_manga >end_page :
|
||||
logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.")
|
||||
current_page_num_manga = (current_offset_manga // page_size) + 1
|
||||
if end_page and current_page_num_manga > end_page:
|
||||
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
|
||||
break
|
||||
try :
|
||||
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 ):
|
||||
logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.")
|
||||
try:
|
||||
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):
|
||||
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
|
||||
break
|
||||
if not posts_batch_manga :
|
||||
logger ("✅ Reached end of posts (Manga Mode fetch all).")
|
||||
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 }.")
|
||||
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 }).")
|
||||
if not posts_batch_manga:
|
||||
logger("✅ Reached end of posts (Manga Mode fetch all).")
|
||||
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}.")
|
||||
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}).")
|
||||
break
|
||||
all_posts_for_manga_mode .extend (posts_batch_manga )
|
||||
current_offset_manga +=page_size
|
||||
time .sleep (0.6 )
|
||||
except RuntimeError as e :
|
||||
if "cancelled by user"in str (e ).lower ():
|
||||
logger (f"ℹ️ Manga mode pagination stopped due to cancellation: {e }")
|
||||
else :
|
||||
logger (f"❌ {e }\n Aborting manga mode pagination.")
|
||||
all_posts_for_manga_mode.extend(posts_batch_manga)
|
||||
current_offset_manga += page_size
|
||||
time.sleep(0.6)
|
||||
except RuntimeError as e:
|
||||
if "cancelled by user" in str(e).lower():
|
||||
logger(f"ℹ️ Manga mode pagination stopped due to cancellation: {e}")
|
||||
else:
|
||||
logger(f"❌ {e}\n Aborting manga mode pagination.")
|
||||
break
|
||||
except Exception as e :
|
||||
logger (f"❌ Unexpected error during manga mode fetch: {e }")
|
||||
traceback .print_exc ()
|
||||
except Exception as e:
|
||||
logger(f"❌ Unexpected error during manga mode fetch: {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
if cancellation_event and cancellation_event .is_set ():return
|
||||
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)...")
|
||||
def sort_key_tuple (post ):
|
||||
published_date_str =post .get ('published')
|
||||
added_date_str =post .get ('added')
|
||||
post_id_str =post .get ('id',"0")
|
||||
primary_sort_val ="0000-00-00T00:00:00"
|
||||
if published_date_str :
|
||||
primary_sort_val =published_date_str
|
||||
elif added_date_str :
|
||||
logger (f" ⚠️ Post ID {post_id_str } missing 'published' date, using 'added' date '{added_date_str }' for primary sorting.")
|
||||
primary_sort_val =added_date_str
|
||||
else :
|
||||
logger (f" ⚠️ Post ID {post_id_str } missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).")
|
||||
secondary_sort_val =0
|
||||
try :
|
||||
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.")
|
||||
if cancellation_event and cancellation_event.is_set(): return
|
||||
if all_posts_for_manga_mode:
|
||||
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
|
||||
if processed_post_ids:
|
||||
original_count = len(all_posts_for_manga_mode)
|
||||
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(all_posts_for_manga_mode)
|
||||
if skipped_count > 0:
|
||||
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
|
||||
def sort_key_tuple(post):
|
||||
published_date_str = post.get('published')
|
||||
added_date_str = post.get('added')
|
||||
post_id_str = post.get('id', "0")
|
||||
primary_sort_val = "0000-00-00T00:00:00"
|
||||
if published_date_str:
|
||||
primary_sort_val = published_date_str
|
||||
elif added_date_str:
|
||||
logger(f" ⚠️ Post ID {post_id_str} missing 'published' date, using 'added' date '{added_date_str}' for primary sorting.")
|
||||
primary_sort_val = added_date_str
|
||||
else:
|
||||
logger(f" ⚠️ Post ID {post_id_str} missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).")
|
||||
secondary_sort_val = 0
|
||||
try:
|
||||
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
|
||||
yield all_posts_for_manga_mode [i :i +page_size ]
|
||||
yield all_posts_for_manga_mode[i:i + page_size]
|
||||
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).")
|
||||
|
||||
|
||||
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
|
||||
current_offset =0
|
||||
processed_target_post_flag =False
|
||||
if start_page and start_page >1 and not target_post_id :
|
||||
current_offset =(start_page -1 )*page_size
|
||||
current_page_num =start_page
|
||||
logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).")
|
||||
while True :
|
||||
if pause_event and pause_event .is_set ():
|
||||
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.")
|
||||
current_page_num = 1
|
||||
current_offset = 0
|
||||
processed_target_post_flag = False
|
||||
if start_page and start_page > 1 and not target_post_id:
|
||||
current_offset = (start_page - 1) * page_size
|
||||
current_page_num = start_page
|
||||
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
|
||||
while True:
|
||||
if pause_event and pause_event.is_set():
|
||||
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
|
||||
time .sleep (0.5 )
|
||||
if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.")
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
logger (" Post fetching loop cancelled.")
|
||||
time.sleep(0.5)
|
||||
if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Post fetching loop cancelled.")
|
||||
break
|
||||
if target_post_id and processed_target_post_flag :
|
||||
if target_post_id and processed_target_post_flag:
|
||||
break
|
||||
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.")
|
||||
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.")
|
||||
break
|
||||
try :
|
||||
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 ):
|
||||
logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).")
|
||||
try:
|
||||
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):
|
||||
logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||
break
|
||||
except RuntimeError as e :
|
||||
if "cancelled by user"in str (e ).lower ():
|
||||
logger (f"ℹ️ Pagination stopped due to cancellation: {e }")
|
||||
else :
|
||||
logger (f"❌ {e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).")
|
||||
except RuntimeError as e:
|
||||
if "cancelled by user" in str(e).lower():
|
||||
logger(f"ℹ️ Pagination stopped due to cancellation: {e}")
|
||||
else:
|
||||
logger(f"❌ {e}\n Aborting pagination at page {current_page_num} (offset {current_offset}).")
|
||||
break
|
||||
except Exception as e :
|
||||
logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }")
|
||||
traceback .print_exc ()
|
||||
except Exception as e:
|
||||
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
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 }).")
|
||||
|
||||
# --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
|
||||
if processed_post_ids:
|
||||
original_count = len(posts_batch)
|
||||
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
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
|
||||
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 )
|
||||
if matching_post :
|
||||
logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).")
|
||||
yield [matching_post ]
|
||||
processed_target_post_flag =True
|
||||
elif not target_post_id :
|
||||
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)
|
||||
if matching_post:
|
||||
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
|
||||
yield [matching_post]
|
||||
processed_target_post_flag = True
|
||||
elif not target_post_id:
|
||||
yield posts_batch
|
||||
if processed_target_post_flag :
|
||||
if processed_target_post_flag:
|
||||
break
|
||||
current_offset +=page_size
|
||||
current_page_num +=1
|
||||
time .sleep (0.6 )
|
||||
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).")
|
||||
current_offset += page_size
|
||||
current_page_num += 1
|
||||
time.sleep(0.6)
|
||||
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).")
|
||||
|
||||
3226
src/core/workers.py
3226
src/core/workers.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -5,9 +5,6 @@ import sys
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
# --- Asset Management ---
|
||||
|
||||
# This global variable will cache the icon so we don't have to load it from disk every time.
|
||||
_app_icon_cache = None
|
||||
|
||||
def get_app_icon_object():
|
||||
@@ -22,17 +19,11 @@ def get_app_icon_object():
|
||||
if _app_icon_cache and not _app_icon_cache.isNull():
|
||||
return _app_icon_cache
|
||||
|
||||
# Declare a single variable to hold the base directory path.
|
||||
app_base_dir = ""
|
||||
|
||||
# Determine the project's base directory, whether running from source or as a bundled app
|
||||
if getattr(sys, 'frozen', False):
|
||||
# The application is frozen (e.g., with PyInstaller).
|
||||
# The base directory is the one containing the executable.
|
||||
app_base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
# The application is running from a .py file.
|
||||
# This path navigates up from src/ui/assets.py to the project root.
|
||||
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
|
||||
@@ -40,7 +31,6 @@ def get_app_icon_object():
|
||||
if os.path.exists(icon_path):
|
||||
_app_icon_cache = QIcon(icon_path)
|
||||
else:
|
||||
# If the icon isn't found, especially in a frozen app, check the _MEIPASS directory as a fallback.
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
|
||||
if os.path.exists(fallback_icon_path):
|
||||
|
||||
@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class CookieHelpDialog(QDialog):
|
||||
"""
|
||||
|
||||
@@ -9,11 +9,9 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# This assumes the new project structure is in place.
|
||||
from ...i18n.translator import get_translation
|
||||
# get_app_icon_object is defined in the main window module in this refactoring plan.
|
||||
from ..main_window import get_app_icon_object
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class DownloadExtractedLinksDialog(QDialog):
|
||||
"""
|
||||
@@ -42,21 +40,15 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
if not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically based on the parent window's size
|
||||
if parent:
|
||||
parent_width = parent.width()
|
||||
parent_height = parent.height()
|
||||
# Use a scaling factor for different screen resolutions
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 768.0
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
base_min_w, base_min_h = 500, 400
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
|
||||
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
|
||||
# Define base dimensions and apply the correct scale factor.
|
||||
base_width, base_height = 600, 450
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -144,16 +136,22 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
|
||||
def _apply_theme(self):
|
||||
"""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'):
|
||||
self.setStyleSheet(self.parent_app.get_dark_theme())
|
||||
if is_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("")
|
||||
|
||||
# Set header text color based on theme
|
||||
header_color = Qt.cyan if is_dark_theme else Qt.blue
|
||||
for i in range(self.links_list_widget.count()):
|
||||
item = self.links_list_widget.item(i)
|
||||
# Headers are not checkable
|
||||
# Headers are not checkable (they have no checkable flag)
|
||||
if not item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setForeground(header_color)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
|
||||
class DownloadHistoryDialog (QDialog ):
|
||||
@@ -23,7 +24,7 @@ class DownloadHistoryDialog (QDialog ):
|
||||
self .last_3_downloaded_entries =last_3_downloaded_entries
|
||||
self .first_processed_entries =first_processed_entries
|
||||
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)
|
||||
if creator_name_cache:
|
||||
@@ -158,6 +159,14 @@ class DownloadHistoryDialog (QDialog ):
|
||||
return get_translation (self .parent_app .current_selected_language ,key ,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 ):
|
||||
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"),
|
||||
|
||||
@@ -21,6 +21,7 @@ from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...core.api_client import download_from_api
|
||||
from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
|
||||
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 .finished_signal .emit ()
|
||||
|
||||
|
||||
class EmptyPopupDialog (QDialog ):
|
||||
"""A simple empty popup dialog."""
|
||||
SCOPE_CHARACTERS ="Characters"
|
||||
@@ -138,13 +140,12 @@ class EmptyPopupDialog (QDialog ):
|
||||
|
||||
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
|
||||
super ().__init__ (parent )
|
||||
self .setMinimumSize (400 ,300 )
|
||||
screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
|
||||
scale_factor =screen_height /768.0
|
||||
self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
|
||||
self.parent_app = parent_app_ref
|
||||
|
||||
self .parent_app =parent_app_ref
|
||||
self .current_scope_mode =self .SCOPE_CHARACTERS
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
self.setMinimumSize(int(400 * scale_factor), int(300 * scale_factor))
|
||||
self.current_scope_mode = self.SCOPE_CREATORS
|
||||
self .app_base_dir =app_base_dir
|
||||
|
||||
app_icon =get_app_icon_object ()
|
||||
@@ -289,9 +290,14 @@ class EmptyPopupDialog (QDialog ):
|
||||
|
||||
self ._retranslate_ui ()
|
||||
|
||||
if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
|
||||
self .setStyleSheet (self .parent_app .get_dark_theme ())
|
||||
|
||||
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("")
|
||||
|
||||
self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor ))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from ...i18n.translator import get_translation
|
||||
from ..assets import get_app_icon_object
|
||||
# Corrected Import: The filename uses PascalCase.
|
||||
from .ExportOptionsDialog import ExportOptionsDialog
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class ErrorFilesDialog(QDialog):
|
||||
"""
|
||||
@@ -42,13 +42,11 @@ class ErrorFilesDialog(QDialog):
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 1080.0
|
||||
base_min_w, base_min_h = 500, 300
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
base_width, base_height = 550, 400
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -132,9 +130,14 @@ class ErrorFilesDialog(QDialog):
|
||||
|
||||
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":
|
||||
if hasattr(self.parent_app, 'get_dark_theme'):
|
||||
self.setStyleSheet(self.parent_app.get_dark_theme())
|
||||
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("")
|
||||
|
||||
def _select_all_items(self):
|
||||
"""Checks all items in the list."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from PyQt5.QtWidgets import (
|
||||
from ...i18n.translator import get_translation
|
||||
# get_app_icon_object is defined in the main window module in this refactoring plan.
|
||||
from ..main_window import get_app_icon_object
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class ExportOptionsDialog(QDialog):
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,7 @@ from ...i18n.translator import get_translation
|
||||
from ..assets import get_app_icon_object
|
||||
from ...utils.network_utils import prepare_cookies_for_request
|
||||
from .CookieHelpDialog import CookieHelpDialog
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class FavoriteArtistsDialog (QDialog ):
|
||||
"""Dialog to display and select favorite artists."""
|
||||
@@ -126,6 +126,21 @@ class FavoriteArtistsDialog (QDialog ):
|
||||
self .artist_list_widget .setVisible (show )
|
||||
|
||||
def _fetch_favorite_artists (self ):
|
||||
|
||||
if self.cookies_config['use_cookie']:
|
||||
# Check if we can load cookies for at least one of the services.
|
||||
kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su")
|
||||
coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su")
|
||||
|
||||
if not kemono_cookies and not coomer_cookies:
|
||||
# If cookies are enabled but none could be loaded, show help and stop.
|
||||
self.status_label.setText(self._tr("fav_artists_cookies_required_status", "Error: Cookies enabled but could not be loaded for any source."))
|
||||
self._logger("Error: Cookies enabled but no valid cookies were loaded. Showing help dialog.")
|
||||
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
|
||||
cookie_help_dialog.exec_()
|
||||
self.download_button.setEnabled(False)
|
||||
return # Stop further execution
|
||||
|
||||
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
|
||||
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from ...utils.network_utils import prepare_cookies_for_request
|
||||
# Corrected Import: Import CookieHelpDialog directly from its own module
|
||||
from .CookieHelpDialog import CookieHelpDialog
|
||||
from ...core.api_client import download_from_api
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class FavoritePostsFetcherThread (QThread ):
|
||||
"""Worker thread to fetch favorite posts and creator names."""
|
||||
|
||||
@@ -1,170 +1,245 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import json
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt, QStandardPaths
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# This assumes the new project structure is in place.
|
||||
from ...i18n.translator import get_translation
|
||||
from ...utils.resolution import get_dark_theme
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...config.constants import (
|
||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY
|
||||
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
|
||||
RESOLUTION_KEY, UI_SCALE_KEY
|
||||
)
|
||||
|
||||
|
||||
class FutureSettingsDialog(QDialog):
|
||||
"""
|
||||
A dialog for managing application-wide settings like theme, language,
|
||||
and saving the default download path.
|
||||
and display options, with an organized layout.
|
||||
"""
|
||||
def __init__(self, parent_app_ref, parent=None):
|
||||
"""
|
||||
Initializes the dialog.
|
||||
|
||||
Args:
|
||||
parent_app_ref (DownloaderApp): A reference to the main application window.
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent_app_ref
|
||||
self.setModal(True)
|
||||
|
||||
# --- Basic Window Setup ---
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 768.0
|
||||
base_min_w, base_min_h = 380, 250
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||
scale_factor = screen_height / 800.0
|
||||
base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
self._apply_theme()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initializes all UI components and layouts for the dialog."""
|
||||
layout = QVBoxLayout(self)
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# --- Appearance Settings ---
|
||||
self.appearance_group_box = QGroupBox()
|
||||
appearance_layout = QVBoxLayout(self.appearance_group_box)
|
||||
# --- Group 1: Interface Settings ---
|
||||
self.interface_group_box = QGroupBox()
|
||||
interface_layout = QGridLayout(self.interface_group_box)
|
||||
|
||||
# Theme
|
||||
self.theme_label = QLabel()
|
||||
self.theme_toggle_button = QPushButton()
|
||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||
appearance_layout.addWidget(self.theme_toggle_button)
|
||||
layout.addWidget(self.appearance_group_box)
|
||||
interface_layout.addWidget(self.theme_label, 0, 0)
|
||||
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
|
||||
# --- Language Settings ---
|
||||
self.language_group_box = QGroupBox()
|
||||
language_group_layout = QVBoxLayout(self.language_group_box)
|
||||
self.language_selection_layout = QHBoxLayout()
|
||||
# UI Scale
|
||||
self.ui_scale_label = QLabel()
|
||||
self.ui_scale_combo_box = QComboBox()
|
||||
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
interface_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
|
||||
# Language
|
||||
self.language_label = QLabel()
|
||||
self.language_selection_layout.addWidget(self.language_label)
|
||||
self.language_combo_box = QComboBox()
|
||||
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
|
||||
self.language_selection_layout.addWidget(self.language_combo_box, 1)
|
||||
language_group_layout.addLayout(self.language_selection_layout)
|
||||
layout.addWidget(self.language_group_box)
|
||||
interface_layout.addWidget(self.language_label, 2, 0)
|
||||
interface_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
|
||||
# --- Download Settings ---
|
||||
self.download_settings_group_box = QGroupBox()
|
||||
download_settings_layout = QVBoxLayout(self.download_settings_group_box)
|
||||
main_layout.addWidget(self.interface_group_box)
|
||||
|
||||
# --- Group 2: Download & Window Settings ---
|
||||
self.download_window_group_box = QGroupBox()
|
||||
download_window_layout = QGridLayout(self.download_window_group_box)
|
||||
|
||||
# Window Size (Resolution)
|
||||
self.window_size_label = QLabel()
|
||||
self.resolution_combo_box = QComboBox()
|
||||
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
download_window_layout.addWidget(self.window_size_label, 0, 0)
|
||||
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
|
||||
|
||||
# Default Path
|
||||
self.default_path_label = QLabel()
|
||||
self.save_path_button = QPushButton()
|
||||
self.save_path_button.clicked.connect(self._save_download_path)
|
||||
download_settings_layout.addWidget(self.save_path_button)
|
||||
layout.addWidget(self.download_settings_group_box)
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
layout.addStretch(1)
|
||||
main_layout.addWidget(self.download_window_group_box)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
|
||||
# --- OK Button ---
|
||||
self.ok_button = QPushButton()
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on the main application's current language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _retranslate_ui(self):
|
||||
"""Sets the text for all translatable UI elements."""
|
||||
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
|
||||
self.appearance_group_box.setTitle(self._tr("appearance_group_title", "Appearance"))
|
||||
self.language_group_box.setTitle(self._tr("language_group_title", "Language Settings"))
|
||||
self.download_settings_group_box.setTitle(self._tr("settings_download_group_title", "Download Settings"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
self._update_theme_toggle_button_text()
|
||||
self._populate_language_combo_box()
|
||||
|
||||
# Group Box Titles
|
||||
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
|
||||
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
|
||||
|
||||
# Interface Group Labels
|
||||
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
||||
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
|
||||
# Download & Window Group Labels
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
|
||||
# Buttons and Controls
|
||||
self._update_theme_toggle_button_text()
|
||||
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path"))
|
||||
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
# Populate dropdowns
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
|
||||
def _apply_theme(self):
|
||||
"""Applies the current theme from the parent application."""
|
||||
if self.parent_app.current_theme == "dark":
|
||||
self.setStyleSheet(self.parent_app.get_dark_theme())
|
||||
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("")
|
||||
|
||||
def _update_theme_toggle_button_text(self):
|
||||
"""Updates the theme button text and tooltip based on the current theme."""
|
||||
if self.parent_app.current_theme == "dark":
|
||||
self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode"))
|
||||
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_light", "Change the application appearance to light."))
|
||||
else:
|
||||
self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode"))
|
||||
self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_dark", "Change the application appearance to dark."))
|
||||
|
||||
def _toggle_theme(self):
|
||||
"""Toggles the application theme and updates the UI."""
|
||||
new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
|
||||
self.parent_app.apply_theme(new_theme)
|
||||
self._retranslate_ui()
|
||||
self.parent_app.settings.setValue(THEME_KEY, new_theme)
|
||||
self.parent_app.settings.sync()
|
||||
self.parent_app.current_theme = new_theme
|
||||
self._apply_theme()
|
||||
if hasattr(self.parent_app, '_apply_theme_and_restart_prompt'):
|
||||
self.parent_app._apply_theme_and_restart_prompt()
|
||||
|
||||
def _populate_display_combo_boxes(self):
|
||||
self.resolution_combo_box.blockSignals(True)
|
||||
self.resolution_combo_box.clear()
|
||||
resolutions = [
|
||||
("Auto", self._tr("auto_resolution", "Auto (System Default)")),
|
||||
("1280x720", "1280 x 720"),
|
||||
("1600x900", "1600 x 900"),
|
||||
("1920x1080", "1920 x 1080 (Full HD)"),
|
||||
("2560x1440", "2560 x 1440 (2K)"),
|
||||
("3840x2160", "3840 x 2160 (4K)")
|
||||
]
|
||||
current_res = self.parent_app.settings.value(RESOLUTION_KEY, "Auto")
|
||||
for res_key, res_name in resolutions:
|
||||
self.resolution_combo_box.addItem(res_name, res_key)
|
||||
if current_res == res_key:
|
||||
self.resolution_combo_box.setCurrentIndex(self.resolution_combo_box.count() - 1)
|
||||
self.resolution_combo_box.blockSignals(False)
|
||||
|
||||
self.ui_scale_combo_box.blockSignals(True)
|
||||
self.ui_scale_combo_box.clear()
|
||||
scales = [
|
||||
(0.5, "50%"),
|
||||
(0.7, "70%"),
|
||||
(0.9, "90%"),
|
||||
(1.0, "100% (Default)"),
|
||||
(1.25, "125%"),
|
||||
(1.50, "150%"),
|
||||
(1.75, "175%"),
|
||||
(2.0, "200%")
|
||||
]
|
||||
|
||||
current_scale = float(self.parent_app.settings.value(UI_SCALE_KEY, 1.0))
|
||||
for scale_val, scale_name in scales:
|
||||
self.ui_scale_combo_box.addItem(scale_name, scale_val)
|
||||
if abs(current_scale - scale_val) < 0.01:
|
||||
self.ui_scale_combo_box.setCurrentIndex(self.ui_scale_combo_box.count() - 1)
|
||||
self.ui_scale_combo_box.blockSignals(False)
|
||||
|
||||
def _display_setting_changed(self):
|
||||
selected_res = self.resolution_combo_box.currentData()
|
||||
selected_scale = self.ui_scale_combo_box.currentData()
|
||||
|
||||
self.parent_app.settings.setValue(RESOLUTION_KEY, selected_res)
|
||||
self.parent_app.settings.setValue(UI_SCALE_KEY, selected_scale)
|
||||
self.parent_app.settings.sync()
|
||||
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Information)
|
||||
msg_box.setWindowTitle(self._tr("display_change_title", "Display Settings Changed"))
|
||||
msg_box.setText(self._tr("language_change_message", "A restart is required for these changes to take effect."))
|
||||
msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
|
||||
restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
|
||||
ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
|
||||
msg_box.setDefaultButton(ok_button)
|
||||
msg_box.exec_()
|
||||
|
||||
if msg_box.clickedButton() == restart_button:
|
||||
self.parent_app._request_restart_application()
|
||||
|
||||
def _populate_language_combo_box(self):
|
||||
"""Populates the language dropdown with available languages."""
|
||||
self.language_combo_box.blockSignals(True)
|
||||
self.language_combo_box.clear()
|
||||
languages = [
|
||||
("en","English"),
|
||||
("ja","日本語 (Japanese)"),
|
||||
("fr","Français (French)"),
|
||||
("de","Deutsch (German)"),
|
||||
("es","Español (Spanish)"),
|
||||
("pt","Português (Portuguese)"),
|
||||
("ru","Русский (Russian)"),
|
||||
("zh_CN","简体中文 (Simplified Chinese)"),
|
||||
("zh_TW","繁體中文 (Traditional Chinese)"),
|
||||
("ko","한국어 (Korean)")
|
||||
("en", "English"), ("ja", "日本語 (Japanese)"), ("fr", "Français (French)"),
|
||||
("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
|
||||
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
|
||||
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
|
||||
]
|
||||
current_lang = self.parent_app.current_selected_language
|
||||
for lang_code, lang_name in languages:
|
||||
self.language_combo_box.addItem(lang_name, lang_code)
|
||||
if self.parent_app.current_selected_language == lang_code:
|
||||
if current_lang == lang_code:
|
||||
self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1)
|
||||
self.language_combo_box.blockSignals(False)
|
||||
|
||||
def _language_selection_changed(self, index):
|
||||
"""Handles the user selecting a new language."""
|
||||
selected_lang_code = self.language_combo_box.itemData(index)
|
||||
if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language:
|
||||
self.parent_app.current_selected_language = selected_lang_code
|
||||
self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code)
|
||||
self.parent_app.settings.sync()
|
||||
self.parent_app.current_selected_language = selected_lang_code
|
||||
|
||||
self._retranslate_ui()
|
||||
if hasattr(self.parent_app, '_retranslate_main_ui'):
|
||||
self.parent_app._retranslate_main_ui()
|
||||
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Information)
|
||||
@@ -180,23 +255,21 @@ class FutureSettingsDialog(QDialog):
|
||||
self.parent_app._request_restart_application()
|
||||
|
||||
def _save_download_path(self):
|
||||
"""Saves the current download path from the main window to settings."""
|
||||
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
|
||||
current_path = self.parent_app.dir_input.text().strip()
|
||||
if current_path:
|
||||
if os.path.isdir(current_path):
|
||||
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
||||
self.parent_app.settings.sync()
|
||||
QMessageBox.information(self,
|
||||
self._tr("settings_save_path_success_title", "Path Saved"),
|
||||
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_invalid_title", "Invalid Path"),
|
||||
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
if current_path and os.path.isdir(current_path):
|
||||
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
|
||||
self.parent_app.settings.sync()
|
||||
QMessageBox.information(self,
|
||||
self._tr("settings_save_path_success_title", "Path Saved"),
|
||||
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
|
||||
elif not current_path:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_empty_title", "Empty Path"),
|
||||
self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
|
||||
else:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("settings_save_path_invalid_title", "Invalid Path"),
|
||||
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
|
||||
else:
|
||||
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")
|
||||
@@ -13,22 +13,25 @@ from PyQt5.QtWidgets import (
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class TourStepWidget(QWidget):
|
||||
"""
|
||||
A custom widget representing a single step or page in the feature guide.
|
||||
It neatly formats a title and its corresponding content.
|
||||
"""
|
||||
def __init__(self, title_text, content_text, parent=None):
|
||||
def __init__(self, title_text, content_text, parent=None, scale=1.0):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(10)
|
||||
|
||||
title_font_size = int(14 * scale)
|
||||
content_font_size = int(11 * scale)
|
||||
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
@@ -42,8 +45,8 @@ class TourStepWidget(QWidget):
|
||||
content_label.setWordWrap(True)
|
||||
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
content_label.setTextFormat(Qt.RichText)
|
||||
content_label.setOpenExternalLinks(True) # Allow opening links in the content
|
||||
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
|
||||
content_label.setOpenExternalLinks(True)
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
|
||||
scroll_area.setWidget(content_label)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
@@ -56,27 +59,38 @@ class HelpGuideDialog (QDialog ):
|
||||
self .steps_data =steps_data
|
||||
self .parent_app =parent_app
|
||||
|
||||
app_icon =get_app_icon_object ()
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
self .setModal (True )
|
||||
self .setFixedSize (650 ,600 )
|
||||
self.setModal(True)
|
||||
self.resize(int(650 * scale), int(600 * scale))
|
||||
|
||||
dialog_font_size = int(11 * scale)
|
||||
|
||||
current_theme_style =""
|
||||
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
|
||||
if hasattr (self .parent_app ,'get_dark_theme'):
|
||||
current_theme_style =self .parent_app .get_dark_theme ()
|
||||
current_theme_style = ""
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
else:
|
||||
current_theme_style = f"""
|
||||
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
|
||||
QLabel {{ color: #1E1E1E; }}
|
||||
QPushButton {{
|
||||
background-color: #E1E1E1;
|
||||
color: #1E1E1E;
|
||||
border: 1px solid #ADADAD;
|
||||
padding: {int(8*scale)}px {int(15*scale)}px;
|
||||
border-radius: 4px;
|
||||
min-height: {int(25*scale)}px;
|
||||
font-size: {dialog_font_size}pt;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #CACACA; }}
|
||||
QPushButton:pressed {{ background-color: #B0B0B0; }}
|
||||
"""
|
||||
|
||||
|
||||
self .setStyleSheet (current_theme_style if current_theme_style else """
|
||||
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
|
||||
QLabel { color: #E0E0E0; }
|
||||
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
|
||||
QPushButton:hover { background-color: #656565; }
|
||||
QPushButton:pressed { background-color: #4A4A4A; }
|
||||
""")
|
||||
self.setStyleSheet(current_theme_style)
|
||||
self ._init_ui ()
|
||||
if self .parent_app :
|
||||
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
|
||||
@@ -97,10 +111,11 @@ class HelpGuideDialog (QDialog ):
|
||||
main_layout .addWidget (self .stacked_widget ,1 )
|
||||
|
||||
self .tour_steps_widgets =[]
|
||||
for title ,content in self .steps_data :
|
||||
step_widget =TourStepWidget (title ,content )
|
||||
self .tour_steps_widgets .append (step_widget )
|
||||
self .stacked_widget .addWidget (step_widget )
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
for title, content in self.steps_data:
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.tour_steps_widgets.append(step_widget)
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
|
||||
|
||||
@@ -115,7 +130,6 @@ class HelpGuideDialog (QDialog ):
|
||||
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
|
||||
assets_base_dir =sys ._MEIPASS
|
||||
else :
|
||||
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
|
||||
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
|
||||
@@ -126,7 +140,9 @@ class HelpGuideDialog (QDialog ):
|
||||
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
|
||||
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
|
||||
|
||||
icon_size =QSize (24 ,24 )
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
icon_dim = int(24 * scale)
|
||||
icon_size = QSize(icon_dim, icon_dim)
|
||||
self .github_button .setIconSize (icon_size )
|
||||
self .instagram_button .setIconSize (icon_size )
|
||||
self .Discord_button .setIconSize (icon_size )
|
||||
|
||||
122
src/ui/dialogs/KeepDuplicatesDialog.py
Normal file
122
src/ui/dialogs/KeepDuplicatesDialog.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# KeepDuplicatesDialog.py
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
|
||||
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
|
||||
)
|
||||
from PyQt5.QtGui import QIntValidator
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
|
||||
|
||||
class KeepDuplicatesDialog(QDialog):
|
||||
"""A dialog to choose the duplicate handling method, with a limit option."""
|
||||
|
||||
def __init__(self, current_mode, current_limit, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
self.selected_mode = current_mode
|
||||
self.limit = current_limit
|
||||
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
|
||||
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
|
||||
self.parent_app._apply_theme_to_widget(self)
|
||||
|
||||
# Set the initial state based on current settings
|
||||
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
|
||||
self.radio_keep_everything.setChecked(True)
|
||||
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
|
||||
else:
|
||||
self.radio_skip_by_hash.setChecked(True)
|
||||
self.limit_input.setEnabled(False)
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initializes the UI components."""
|
||||
main_layout = QVBoxLayout(self)
|
||||
info_label = QLabel()
|
||||
info_label.setWordWrap(True)
|
||||
main_layout.addWidget(info_label)
|
||||
|
||||
options_group = QGroupBox()
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
self.button_group = QButtonGroup(self)
|
||||
|
||||
# --- Skip by Hash Option ---
|
||||
self.radio_skip_by_hash = QRadioButton()
|
||||
self.button_group.addButton(self.radio_skip_by_hash)
|
||||
options_layout.addWidget(self.radio_skip_by_hash)
|
||||
|
||||
# --- Keep Everything Option with Limit Input ---
|
||||
keep_everything_layout = QHBoxLayout()
|
||||
self.radio_keep_everything = QRadioButton()
|
||||
self.button_group.addButton(self.radio_keep_everything)
|
||||
keep_everything_layout.addWidget(self.radio_keep_everything)
|
||||
keep_everything_layout.addStretch(1)
|
||||
|
||||
self.limit_label = QLabel()
|
||||
self.limit_input = QLineEdit()
|
||||
self.limit_input.setValidator(QIntValidator(0, 99))
|
||||
self.limit_input.setFixedWidth(50)
|
||||
keep_everything_layout.addWidget(self.limit_label)
|
||||
keep_everything_layout.addWidget(self.limit_input)
|
||||
options_layout.addLayout(keep_everything_layout)
|
||||
|
||||
main_layout.addWidget(options_group)
|
||||
|
||||
# --- OK and Cancel buttons ---
|
||||
button_layout = QHBoxLayout()
|
||||
self.ok_button = QPushButton()
|
||||
self.cancel_button = QPushButton()
|
||||
button_layout.addStretch(1)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
# --- Connections ---
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
if self.parent_app and callable(get_translation):
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _retranslate_ui(self):
|
||||
"""Sets the text for UI elements."""
|
||||
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
|
||||
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
|
||||
"Choose how to handle files that have identical content to already downloaded files."))
|
||||
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
|
||||
|
||||
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
|
||||
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
|
||||
|
||||
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
|
||||
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
|
||||
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
|
||||
"Set a limit for identical files to keep. 0 means no limit."))
|
||||
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
|
||||
|
||||
def accept(self):
|
||||
"""Sets the selected mode and limit when OK is clicked."""
|
||||
if self.radio_keep_everything.isChecked():
|
||||
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
|
||||
try:
|
||||
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
|
||||
except ValueError:
|
||||
self.limit = 0
|
||||
else:
|
||||
self.selected_mode = DUPLICATE_HANDLING_HASH
|
||||
self.limit = 0
|
||||
super().accept()
|
||||
|
||||
def get_selected_options(self):
|
||||
"""Returns the chosen mode and limit as a dictionary."""
|
||||
return {"mode": self.selected_mode, "limit": self.limit}
|
||||
@@ -8,13 +8,12 @@ from PyQt5.QtWidgets import (
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class KnownNamesFilterDialog(QDialog):
|
||||
"""
|
||||
A dialog to select names from the Known.txt list to add to the main
|
||||
character filter input field. This provides a convenient way for users
|
||||
|
||||
to reuse their saved names and groups for filtering downloads.
|
||||
"""
|
||||
|
||||
@@ -40,11 +39,10 @@ class KnownNamesFilterDialog(QDialog):
|
||||
|
||||
# Set window size dynamically
|
||||
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
base_width, base_height = 460, 450
|
||||
scale_factor_h = screen_geometry.height() / 1080.0
|
||||
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5))
|
||||
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor))
|
||||
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -102,8 +100,14 @@ class KnownNamesFilterDialog(QDialog):
|
||||
|
||||
def _apply_theme(self):
|
||||
"""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":
|
||||
self.setStyleSheet(self.parent_app.get_dark_theme())
|
||||
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("")
|
||||
|
||||
def _populate_list_widget(self):
|
||||
"""Populates the list widget with the known names."""
|
||||
|
||||
96
src/ui/dialogs/MoreOptionsDialog.py
Normal file
96
src/ui/dialogs/MoreOptionsDialog.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class MoreOptionsDialog(QDialog):
|
||||
"""
|
||||
A dialog for selecting a scope, export format, and single PDF option.
|
||||
"""
|
||||
SCOPE_CONTENT = "content"
|
||||
SCOPE_COMMENTS = "comments"
|
||||
|
||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
self.setWindowTitle("More Options")
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
# ... (Layout and other widgets remain the same) ...
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.description_label = QLabel("Please choose the scope for the action:")
|
||||
layout.addWidget(self.description_label)
|
||||
self.radio_button_group = QButtonGroup(self)
|
||||
self.radio_content = QRadioButton("Description/Content")
|
||||
self.radio_comments = QRadioButton("Comments (Not Working)")
|
||||
self.radio_button_group.addButton(self.radio_content)
|
||||
self.radio_button_group.addButton(self.radio_comments)
|
||||
layout.addWidget(self.radio_content)
|
||||
layout.addWidget(self.radio_comments)
|
||||
|
||||
if current_scope == self.SCOPE_COMMENTS:
|
||||
self.radio_comments.setChecked(True)
|
||||
else:
|
||||
self.radio_content.setChecked(True)
|
||||
|
||||
export_layout = QHBoxLayout()
|
||||
export_label = QLabel("Export as:")
|
||||
self.format_combo = QComboBox()
|
||||
self.format_combo.addItems(["PDF", "DOCX", "TXT"])
|
||||
|
||||
if current_format and current_format.upper() in ["PDF", "DOCX", "TXT"]:
|
||||
self.format_combo.setCurrentText(current_format.upper())
|
||||
else:
|
||||
self.format_combo.setCurrentText("PDF")
|
||||
|
||||
export_layout.addWidget(export_label)
|
||||
export_layout.addWidget(self.format_combo)
|
||||
export_layout.addStretch()
|
||||
layout.addLayout(export_layout)
|
||||
|
||||
# --- UPDATED: Single PDF Checkbox ---
|
||||
self.single_pdf_checkbox = QCheckBox("Single PDF")
|
||||
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
|
||||
self.single_pdf_checkbox.setChecked(single_pdf_checked)
|
||||
layout.addWidget(self.single_pdf_checkbox)
|
||||
|
||||
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
|
||||
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
self.setLayout(layout)
|
||||
self._apply_theme()
|
||||
def update_single_pdf_checkbox_state(self, text):
|
||||
"""Enable the Single PDF checkbox only if the format is PDF."""
|
||||
is_pdf = (text.upper() == "PDF")
|
||||
self.single_pdf_checkbox.setEnabled(is_pdf)
|
||||
if not is_pdf:
|
||||
self.single_pdf_checkbox.setChecked(False)
|
||||
|
||||
def get_selected_scope(self):
|
||||
if self.radio_comments.isChecked():
|
||||
return self.SCOPE_COMMENTS
|
||||
return self.SCOPE_CONTENT
|
||||
|
||||
def get_selected_format(self):
|
||||
return self.format_combo.currentText().lower()
|
||||
|
||||
def get_single_pdf_state(self):
|
||||
"""Returns the state of the Single PDF checkbox."""
|
||||
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("")
|
||||
77
src/ui/dialogs/SinglePDF.py
Normal file
77
src/ui/dialogs/SinglePDF.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# SinglePDF.py
|
||||
|
||||
import os
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
FPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
FPDF_AVAILABLE = False
|
||||
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def header(self):
|
||||
# No header
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
# Position at 1.5 cm from bottom
|
||||
self.set_y(-15)
|
||||
self.set_font('DejaVu', '', 8)
|
||||
# Page number
|
||||
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):
|
||||
"""
|
||||
Creates a single PDF from a list of post titles and content.
|
||||
|
||||
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:
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||
return False
|
||||
|
||||
if not posts_data:
|
||||
logger(" No text content was collected to create a PDF.")
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
raise RuntimeError("Font file not found.")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
|
||||
logger(" PDF may not support all characters. Falling back to default Arial font.")
|
||||
pdf.set_font('Arial', '', 12)
|
||||
pdf.set_font('Arial', 'B', 16)
|
||||
|
||||
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for post in posts_data:
|
||||
pdf.add_page()
|
||||
# Post Title
|
||||
pdf.set_font('DejaVu', 'B', 16)
|
||||
|
||||
# vvv THIS LINE IS CORRECTED vvv
|
||||
# We explicitly set align='L' and remove the incorrect positional arguments.
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
|
||||
pdf.ln(5) # Add a little space after the title
|
||||
|
||||
# Post Content
|
||||
pdf.set_font('DejaVu', '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
155
src/ui/dialogs/SupportDialog.py
Normal file
155
src/ui/dialogs/SupportDialog.py
Normal 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("")
|
||||
@@ -12,6 +12,7 @@ from PyQt5.QtWidgets import (
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
from ...config.constants import (
|
||||
CONFIG_ORGANIZATION_NAME
|
||||
)
|
||||
@@ -150,8 +151,9 @@ class TourDialog(QDialog):
|
||||
|
||||
def _apply_theme(self):
|
||||
"""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":
|
||||
self.setStyleSheet(self.parent_app.get_dark_theme())
|
||||
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; }")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ MAX_FILENAME_COMPONENT_LENGTH = 150
|
||||
|
||||
# Sets of file extensions for quick type checking
|
||||
IMAGE_EXTENSIONS = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
|
||||
580
src/utils/resolution.py
Normal file
580
src/utils/resolution.py
Normal 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()
|
||||
Reference in New Issue
Block a user