12 Commits

Author SHA1 Message Date
Yuvi9587
77bd428b91 Commit 2025-12-25 21:56:04 +05:30
Yuvi9587
4bf57eb752 Socks 4 and 5 proxy support 2025-12-24 09:27:01 +05:30
Yuvi9587
de202961a0 Proxy Type Dropdown List 2025-12-24 09:26:43 +05:30
Yuvi9587
e806b6de66 Update deviantart_downloader_thread.py 2025-12-24 09:26:07 +05:30
Yuvi9587
cb8dd3b7f3 Proxy Type Key 2025-12-24 09:26:04 +05:30
Yuvi9587
5a8c151c97 Deviant Support fix 2025-12-23 22:52:50 +05:30
Yuvi9587
50ba60a461 Fixed devient download 2025-12-23 21:27:21 +05:30
Yuvi9587
23521e7060 Added "Proxy/Network" Tab 2025-12-23 21:27:08 +05:30
Yuvi9587
f9c504b936 Proxy Support 2025-12-23 21:26:49 +05:30
Yuvi9587
efa0abd0f1 Fixed devient download (Kinda) 2025-12-23 21:26:34 +05:30
Yuvi9587
7d76d00470 Proxy 2025-12-23 21:26:18 +05:30
Yuvi9587
1494d3f456 Proxy Support Keys 2025-12-23 21:26:11 +05:30
14 changed files with 714 additions and 274 deletions

View File

@@ -68,6 +68,15 @@ DISCORD_TOKEN_KEY = "discord/token"
POST_DOWNLOAD_ACTION_KEY = "postDownloadAction"
# --- Proxy / Network Keys ---
PROXY_ENABLED_KEY = "proxy/enabled"
PROXY_HOST_KEY = "proxy/host"
PROXY_PORT_KEY = "proxy/port"
PROXY_USERNAME_KEY = "proxy/username"
PROXY_PASSWORD_KEY = "proxy/password"
PROXY_TYPE_KEY = "proxy_type"
# --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>"
LOG_DISPLAY_LINKS = "links"

View File

@@ -5,7 +5,8 @@ import time
import random
from urllib.parse import urlparse
def get_chapter_list(scraper, series_url, logger_func):
# 1. Update arguments to accept proxies=None
def get_chapter_list(scraper, series_url, logger_func, proxies=None):
"""
Checks if a URL is a series page and returns a list of all chapter URLs if it is.
Relies on a passed-in scraper session for connection.
@@ -16,9 +17,13 @@ def get_chapter_list(scraper, series_url, logger_func):
response = None
max_retries = 8
# 2. Define smart timeout logic
req_timeout = (30, 120) if proxies else 30
for attempt in range(max_retries):
try:
response = scraper.get(series_url, headers=headers, timeout=30)
# 3. Add proxies, verify=False, and the new timeout
response = scraper.get(series_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
response.raise_for_status()
logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.")
break
@@ -53,7 +58,8 @@ def get_chapter_list(scraper, series_url, logger_func):
logger_func(f" [AllComic] ❌ Error parsing chapters after successful connection: {e}")
return []
def fetch_chapter_data(scraper, chapter_url, logger_func):
# 4. Update arguments here too
def fetch_chapter_data(scraper, chapter_url, logger_func, proxies=None):
"""
Fetches the comic title, chapter title, and image URLs for a single chapter page.
Relies on a passed-in scraper session for connection.
@@ -64,9 +70,14 @@ def fetch_chapter_data(scraper, chapter_url, logger_func):
response = None
max_retries = 8
# 5. Define smart timeout logic again
req_timeout = (30, 120) if proxies else 30
for attempt in range(max_retries):
try:
response = scraper.get(chapter_url, headers=headers, timeout=30)
# 6. Add proxies, verify=False, and timeout
response = scraper.get(chapter_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
response.raise_for_status()
break
except requests.RequestException as e:

View File

@@ -12,7 +12,7 @@ from ..config.constants import (
)
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None, proxies=None):
"""
Fetches a single page of posts from the API with robust retry logic.
"""
@@ -40,8 +40,11 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
log_message += f" (Attempt {attempt + 1}/{max_retries})"
logger(log_message)
request_timeout = (30, 120) if proxies else (15, 60)
try:
with requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) as response:
with requests.get(paginated_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False) as response:
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
@@ -81,7 +84,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
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):
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None, proxies=None):
"""
Fetches the full data, including the 'content' field, for a single post using cloudscraper.
"""
@@ -92,7 +95,11 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
scraper = None
try:
scraper = cloudscraper.create_scraper()
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict)
# Keep the 300s read timeout for both, but increase connect timeout for proxies
request_timeout = (30, 300) if proxies else (15, 300)
response = scraper.get(post_api_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False)
response.raise_for_status()
full_post_data = response.json()
@@ -111,7 +118,7 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
scraper.close()
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None, proxies=None):
"""Fetches all comments for a specific post."""
if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Comment fetch operation cancelled by user.")
@@ -120,7 +127,9 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
logger(f" Fetching comments: {comments_api_url}")
try:
with requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict) as response:
request_timeout = (30, 60) if proxies else (10, 30)
with requests.get(comments_api_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False) as response:
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
@@ -143,7 +152,8 @@ def download_from_api(
app_base_dir=None,
manga_filename_style_for_sort_check=None,
processed_post_ids=None,
fetch_all_first=False
fetch_all_first=False,
proxies=None
):
parsed_input_url_for_domain = urlparse(api_url_input)
api_domain = parsed_input_url_for_domain.netloc
@@ -179,7 +189,9 @@ def download_from_api(
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:
with requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api) as direct_response:
request_timeout = (30, 60) if proxies else (10, 30)
with requests.get(direct_post_api_url, headers=headers, timeout=request_timeout, cookies=cookies_for_api, proxies=proxies, verify=False) as direct_response:
direct_response.raise_for_status()
direct_response.encoding = 'utf-8'
direct_post_data = direct_response.json()
@@ -249,7 +261,7 @@ def download_from_api(
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)
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
if not isinstance(posts_batch_manga, list):
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break
@@ -351,7 +363,7 @@ def download_from_api(
break
try:
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
if not isinstance(raw_posts_batch, list):
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
break

View File

@@ -11,11 +11,28 @@ class DeviantArtClient:
CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1"
BASE_API = "https://www.deviantart.com/api/v1/oauth2"
def __init__(self, logger_func=print):
# 1. Accept proxies in init
def __init__(self, logger_func=print, proxies=None):
self.session = requests.Session()
# 2. Configure Session with Proxy & SSL settings immediately
if proxies:
self.session.proxies.update(proxies)
self.session.verify = False # Ignore SSL for proxies
self.proxies_enabled = True
else:
self.proxies_enabled = False
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': '*/*',
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
})
self.access_token = None
self.logger = logger_func
@@ -33,7 +50,10 @@ class DeviantArtClient:
"client_id": self.CLIENT_ID,
"client_secret": self.CLIENT_SECRET
}
resp = self.session.post(url, data=data, timeout=10)
# 3. Smart timeout (longer if proxy)
req_timeout = 30 if self.proxies_enabled else 10
resp = self.session.post(url, data=data, timeout=req_timeout)
resp.raise_for_status()
data = resp.json()
self.access_token = data.get("access_token")
@@ -55,12 +75,28 @@ class DeviantArtClient:
retries = 0
max_retries = 4
backoff_delay = 2
# 4. Smart timeout
req_timeout = 30 if self.proxies_enabled else 20
while True:
try:
resp = self.session.get(url, params=params, timeout=20)
# Handle Token Expiration (401)
resp = self.session.get(url, params=params, timeout=req_timeout)
# 429: Rate Limit
if resp.status_code == 429:
retry_after = resp.headers.get('Retry-After')
if retry_after:
sleep_time = int(retry_after) + 2 # Add buffer
else:
# 5. Increase default wait time for 429s
sleep_time = 15
self._log_once(sleep_time, f" [DeviantArt] ⚠️ Rate limit (429). Sleeping {sleep_time}s...")
time.sleep(sleep_time)
continue
# 401: Token Expired (Refresh and Retry)
if resp.status_code == 401:
self.logger(" [DeviantArt] Token expired. Refreshing...")
if self.authenticate():
@@ -69,64 +105,47 @@ class DeviantArtClient:
else:
raise Exception("Failed to refresh token")
# Handle Rate Limiting (429)
if resp.status_code == 429:
if retries < max_retries:
retry_after = resp.headers.get('Retry-After')
if retry_after:
sleep_time = int(retry_after) + 1
msg = f" [DeviantArt] ⚠️ Rate limit (Server says wait {sleep_time}s)."
else:
sleep_time = backoff_delay * (2 ** retries)
msg = f" [DeviantArt] ⚠️ Rate limit reached. Retrying in {sleep_time}s..."
# --- THREAD-SAFE LOGGING CHECK ---
should_log = False
with self.log_lock:
if sleep_time not in self.logged_waits:
self.logged_waits.add(sleep_time)
should_log = True
if should_log:
self.logger(msg)
time.sleep(sleep_time)
retries += 1
continue
else:
resp.raise_for_status()
if 400 <= resp.status_code < 500:
resp.raise_for_status()
if 500 <= resp.status_code < 600:
resp.raise_for_status()
resp.raise_for_status()
# Clear log history on success so we get warned again if limits return later
with self.log_lock:
if self.logged_waits:
self.logged_waits.clear()
self.logged_waits.clear()
return resp.json()
except requests.exceptions.HTTPError as e:
if e.response is not None and 400 <= e.response.status_code < 500:
raise e
pass
except requests.exceptions.RequestException as e:
if retries < max_retries:
# Using the lock here too to prevent connection error spam
should_log = False
with self.log_lock:
if "conn_error" not in self.logged_waits:
self.logged_waits.add("conn_error")
should_log = True
if should_log:
self.logger(f" [DeviantArt] Connection error: {e}. Retrying...")
time.sleep(2)
self._log_once("conn_error", f" [DeviantArt] Connection error: {e}. Retrying...")
time.sleep(backoff_delay)
retries += 1
continue
raise e
def _log_once(self, key, message):
"""Helper to avoid spamming the same log message during loops."""
should_log = False
with self.log_lock:
if key not in self.logged_waits:
self.logged_waits.add(key)
should_log = True
if should_log:
self.logger(message)
def get_deviation_uuid(self, url):
"""Scrapes the deviation page to find the UUID."""
try:
resp = self.session.get(url, timeout=15)
req_timeout = 30 if self.proxies_enabled else 15
resp = self.session.get(url, timeout=req_timeout)
match = re.search(r'"deviationUuid":"([^"]+)"', resp.text)
if match:
return match.group(1)

View File

@@ -3,7 +3,7 @@ import time
import os
import json
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
from concurrent.futures import ThreadPoolExecutor, as_completed, Future, CancelledError
from .api_client import download_from_api
from .workers import PostProcessorWorker
from ..config.constants import (
@@ -113,6 +113,29 @@ class DownloadManager:
self.is_running = False # Allow another session to start if needed
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)})
def _get_proxies_from_config(self, config):
"""Constructs the proxy dictionary from the config."""
if not config.get('proxy_enabled'):
return None
host = config.get('proxy_host')
port = config.get('proxy_port')
if not host or not port:
return None
proxy_str = f"http://{host}:{port}"
# Add auth if provided
user = config.get('proxy_username')
password = config.get('proxy_password')
if user and password:
proxy_str = f"http://{user}:{password}@{host}:{port}"
return {
"http": proxy_str,
"https": proxy_str
}
def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
"""
Fetches posts from the API in batches and submits them as tasks to a thread pool.
@@ -126,6 +149,9 @@ class DownloadManager:
session_processed_ids = set(restore_data.get('processed_post_ids', [])) if restore_data else set()
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_ids)
# Helper to get proxies
proxies = self._get_proxies_from_config(config)
if restore_data and 'all_posts_data' in restore_data:
# This logic for session restore remains as it relies on a pre-fetched list
@@ -143,12 +169,20 @@ class DownloadManager:
for post_data in posts_to_process:
if self.cancellation_event.is_set():
break
worker = PostProcessorWorker(post_data, config, self.progress_queue)
worker_args = self._map_config_to_worker_args(post_data, config)
# Manually inject proxies here if _map_config_to_worker_args didn't catch it (though it should)
worker_args['proxies'] = proxies
worker = PostProcessorWorker(**worker_args)
future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
else:
# --- Streaming Logic ---
if proxies:
self._log(f" 🌐 Using Proxy: {config.get('proxy_host')}:{config.get('proxy_port')}")
post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
@@ -156,7 +190,8 @@ class DownloadManager:
end_page=config.get('end_page'),
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
cookies_dict=None # Cookie handling handled inside client if needed, or update if passed
cookies_dict=None, # Cookie handling handled inside client if needed
proxies=proxies # <--- NEW: Pass proxies to API client
)
for post_batch in post_generator:
@@ -169,23 +204,16 @@ class DownloadManager:
new_posts_batch = [p for p in post_batch if p.get('id') not in processed_ids]
if not new_posts_batch:
# Log skipped count for UI feedback if needed, already handled in api_client usually
continue
# Update total posts dynamically as we find them
self.total_posts += len(new_posts_batch)
# Note: total_posts in streaming is a "running total of found posts", not absolute total
for post_data in new_posts_batch:
if self.cancellation_event.is_set():
break
# Pass explicit args or config to worker
# Ideally PostProcessorWorker should accept the whole config dict or mapped args
# For now assuming PostProcessorWorker takes (post_data, config_dict, queue)
# OR we map the config to the args expected by PostProcessorWorker.__init__
# MAPPING CONFIG TO WORKER ARGS (Safe wrapper)
# MAPPING CONFIG TO WORKER ARGS
worker_args = self._map_config_to_worker_args(post_data, config)
worker = PostProcessorWorker(**worker_args)
@@ -193,7 +221,7 @@ class DownloadManager:
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
# Small sleep to prevent UI freeze if batches are huge and instant
# Small sleep to prevent UI freeze
time.sleep(0.01)
except Exception as e:
@@ -205,6 +233,9 @@ class DownloadManager:
def _map_config_to_worker_args(self, post_data, config):
"""Helper to map the flat config dict to PostProcessorWorker arguments."""
# Get proxy dict
proxies = self._get_proxies_from_config(config)
# This mirrors the arguments in workers.py PostProcessorWorker.__init__
return {
'post_data': post_data,
@@ -221,29 +252,27 @@ class DownloadManager:
'custom_folder_name': config.get('custom_folder_name'),
'compress_images': config.get('compress_images'),
'download_thumbnails': config.get('download_thumbnails'),
'service': config.get('service') or 'unknown', # extracted elsewhere
'service': config.get('service') or 'unknown',
'user_id': config.get('user_id') or 'unknown',
'pause_event': self.pause_event,
'api_url_input': config.get('api_url'),
'cancellation_event': self.cancellation_event,
'downloaded_files': None, # Managed per worker or global if passed
'downloaded_files': None,
'downloaded_file_hashes': None,
'downloaded_files_lock': None,
'downloaded_file_hashes_lock': None,
# Add other necessary fields from config...
'manga_mode_active': config.get('manga_mode_active'),
'manga_filename_style': config.get('manga_filename_style'),
'manga_custom_filename_format': config.get('custom_manga_filename_format', "{published} {title}"), # Pass custom format
'manga_custom_filename_format': config.get('custom_manga_filename_format', "{published} {title}"),
'manga_custom_date_format': config.get('manga_custom_date_format', "YYYY-MM-DD"),
'use_multithreading': config.get('use_multithreading', True),
# Ensure defaults for others
'proxies': proxies, # <--- NEW: Pass proxies to worker
}
def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile."""
# Extract name logic here or assume config has it
# ... (Same as your existing code)
self.current_creator_name_for_profile = "Unknown" # Placeholder
self.current_creator_name_for_profile = "Unknown"
# You should ideally extract name from URL or config here if available
return {}

View File

@@ -1,31 +1,35 @@
import requests
import cloudscraper
import json
def fetch_nhentai_gallery(gallery_id, logger=print):
# 1. Update arguments to accept proxies=None
def fetch_nhentai_gallery(gallery_id, logger=print, proxies=None):
"""
Fetches the metadata for a single nhentai gallery using cloudscraper to bypass Cloudflare.
Args:
gallery_id (str or int): The ID of the nhentai gallery.
logger (function): A function to log progress and error messages.
Returns:
dict: A dictionary containing the gallery's metadata if successful, otherwise None.
Fetches the metadata for a single nhentai gallery.
Switched to standard requests to support proxies with self-signed certs.
"""
api_url = f"https://nhentai.net/api/gallery/{gallery_id}"
scraper = cloudscraper.create_scraper()
# 2. Use a real User-Agent to avoid immediate blocking
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}
logger(f" Fetching nhentai gallery metadata from: {api_url}")
# 3. Smart timeout logic
req_timeout = (30, 120) if proxies else 20
try:
# Use the scraper to make the GET request
response = scraper.get(api_url, timeout=20)
# 4. Use requests.get with proxies, verify=False, and timeout
response = requests.get(api_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
if response.status_code == 404:
logger(f" ❌ Gallery not found (404): ID {gallery_id}")
return None
elif response.status_code == 403:
logger(f" ❌ Access Denied (403): Cloudflare blocked the request. Try a different proxy or User-Agent.")
return None
response.raise_for_status()
@@ -36,9 +40,9 @@ def fetch_nhentai_gallery(gallery_id, logger=print):
gallery_data['pages'] = gallery_data.pop('images')['pages']
return gallery_data
else:
logger(" ❌ API response is missing essential keys (id, media_id, or images).")
logger(" ❌ API response is missing essential keys (id, media_id, images).")
return None
except Exception as e:
logger(f"An error occurred while fetching gallery {gallery_id}: {e}")
logger(f"Error fetching nhentai metadata: {e}")
return None

View File

@@ -133,7 +133,8 @@ class PostProcessorWorker:
sfp_threshold=None,
handle_unknown_mode=False,
creator_name_cache=None,
add_info_in_pdf=False
add_info_in_pdf=False,
proxies=None
):
self.post = post_data
@@ -208,9 +209,8 @@ class PostProcessorWorker:
self.sfp_threshold = sfp_threshold
self.handle_unknown_mode = handle_unknown_mode
self.creator_name_cache = creator_name_cache
#-- New assign --
self.add_info_in_pdf = add_info_in_pdf
#-- New assign --
self.proxies = proxies
if self.compress_images and Image is None:
@@ -263,7 +263,7 @@ class PostProcessorWorker:
new_url = parsed_url._replace(netloc=new_domain).geturl()
try:
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True) as resp:
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True, proxies=self.proxies, verify=False) as resp:
if resp.status_code == 200:
return new_url
except requests.RequestException:
@@ -338,7 +338,8 @@ class PostProcessorWorker:
api_original_filename_for_size_check = file_info.get('_original_name_for_log', file_info.get('name'))
try:
# Use a stream=True HEAD request to get headers without downloading the body
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True, proxies=self.proxies, verify=False) as head_response:
head_response.raise_for_status()
content_length = head_response.headers.get('Content-Length')
if content_length:
@@ -672,7 +673,7 @@ class PostProcessorWorker:
current_url_to_try = file_url
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
if response.status_code == 403 and ('kemono.' in current_url_to_try or 'coomer.' in current_url_to_try):
self.logger(f" ⚠️ Got 403 Forbidden for '{api_original_filename}'. Attempting subdomain rotation...")
@@ -681,8 +682,7 @@ class PostProcessorWorker:
self.logger(f" Retrying with new URL: {new_url}")
file_url = new_url
response.close() # Close the old response
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
response.raise_for_status()
# --- REVISED AND MOVED SIZE CHECK LOGIC ---
@@ -1104,8 +1104,8 @@ class PostProcessorWorker:
'Referer': creator_page_url,
'Accept': 'text/css'
}
cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain)
full_post_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain)
full_post_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies, proxies=self.proxies)
if full_post_data:
self.logger(" ✅ Full post data fetched successfully.")
self.post = full_post_data
@@ -1306,13 +1306,17 @@ class PostProcessorWorker:
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.")
api_domain_for_comments = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
# Fetch comments (Indented correctly now)
comments_data = fetch_post_comments(
api_domain_for_comments, self.service, self.user_id, post_id,
headers, self.logger, self.cancellation_event, self.pause_event,
cookies_dict=prepare_cookies_for_request(
self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger
)
),
proxies=self.proxies
)
if comments_data:
self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.")
for comment_item_idx, comment_item in enumerate(comments_data):
@@ -1339,8 +1343,8 @@ class PostProcessorWorker:
except RuntimeError as e_fetch_comment:
self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}")
except Exception as e_generic_comment:
self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}")
self.logger(f" [Char Scope: Comments] Phase 2 Result: post_is_candidate_by_comment_char_match = {post_is_candidate_by_comment_char_match}")
self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}")
else:
self.logger(f" [Char Scope: Comments] Phase 2: Skipped comment check for post ID '{post_id}' because a file match already made it a candidate.")
@@ -2327,9 +2331,10 @@ class DownloadThread(QThread):
manga_custom_filename_format="{published} {title}",
manga_custom_date_format="YYYY-MM-DD" ,
sfp_threshold=None,
creator_name_cache=None
creator_name_cache=None,
proxies=None
):
super().__init__()
self.api_url_input = api_url_input
self.output_dir = output_dir
@@ -2404,6 +2409,7 @@ class DownloadThread(QThread):
self.domain_override = domain_override
self.sfp_threshold = sfp_threshold
self.creator_name_cache = creator_name_cache
self.proxies = proxies
if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
@@ -2437,6 +2443,7 @@ class DownloadThread(QThread):
self.logger(" Starting post fetch (single-threaded download process)...")
# --- FIX: Removed duplicate proxies argument here ---
post_generator = download_from_api(
self.api_url_input,
logger=self.logger,
@@ -2451,7 +2458,8 @@ class DownloadThread(QThread):
app_base_dir=self.app_base_dir,
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
processed_post_ids=self.processed_post_ids_set,
fetch_all_first=self.fetch_first
fetch_all_first=self.fetch_first,
proxies=self.proxies
)
for posts_batch_data in post_generator:
@@ -2464,6 +2472,7 @@ class DownloadThread(QThread):
was_process_cancelled = True
break
# --- FIX: Ensure 'proxies' is in this dictionary ---
worker_args = {
'post_data': individual_post_data,
'emitter': worker_signals_obj,
@@ -2532,7 +2541,8 @@ class DownloadThread(QThread):
'archive_only_mode': self.archive_only_mode,
'manga_custom_filename_format': self.manga_custom_filename_format,
'manga_custom_date_format': self.manga_custom_date_format,
'sfp_threshold': self.sfp_threshold
'sfp_threshold': self.sfp_threshold,
'proxies': self.proxies
}
post_processing_worker = PostProcessorWorker(**worker_args)

View File

@@ -19,12 +19,14 @@ class AllcomicDownloadThread(QThread):
finished_signal = pyqtSignal(int, int, bool)
overall_progress_signal = pyqtSignal(int, int)
def __init__(self, url, output_dir, parent=None):
# 1. Update __init__ to accept proxies
def __init__(self, url, output_dir, parent=None, proxies=None):
super().__init__(parent)
self.comic_url = url
self.output_dir = output_dir
self.is_cancelled = False
self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
self.proxies = proxies # Store the proxies
def _check_pause(self):
if self.is_cancelled: return True
@@ -40,13 +42,19 @@ class AllcomicDownloadThread(QThread):
grand_total_dl = 0
grand_total_skip = 0
# Create the scraper session ONCE for the entire job
scraper = cloudscraper.create_scraper(
browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True}
)
if self.proxies:
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
else:
self.progress_signal.emit(" 🌍 Network: Direct Connection (No Proxy)")
# Pass the scraper to the function
chapters_to_download = allcomic_get_list(scraper, self.comic_url, self.progress_signal.emit)
scraper = requests.Session()
scraper.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
})
# 2. Pass self.proxies to get_chapter_list
chapters_to_download = allcomic_get_list(scraper, self.comic_url, self.progress_signal.emit, proxies=self.proxies)
if not chapters_to_download:
chapters_to_download = [self.comic_url]
@@ -57,8 +65,9 @@ class AllcomicDownloadThread(QThread):
if self._check_pause(): break
self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --")
# Pass the scraper to the function
comic_title, chapter_title, image_urls = allcomic_fetch_data(scraper, chapter_url, self.progress_signal.emit)
# 3. Pass self.proxies to fetch_chapter_data
comic_title, chapter_title, image_urls = allcomic_fetch_data(scraper, chapter_url, self.progress_signal.emit, proxies=self.proxies)
if not image_urls:
self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
@@ -80,6 +89,9 @@ class AllcomicDownloadThread(QThread):
self.overall_progress_signal.emit(total_files_in_chapter, 0)
headers = {'Referer': chapter_url}
# 4. Define smart timeout for images
img_timeout = (30, 120) if self.proxies else 60
for i, img_url in enumerate(image_urls):
if self._check_pause(): break
@@ -97,8 +109,9 @@ class AllcomicDownloadThread(QThread):
if self._check_pause(): break
try:
self.progress_signal.emit(f" Downloading ({i+1}/{total_files_in_chapter}): '{filename}' (Attempt {attempt + 1})...")
# Use the persistent scraper object
response = scraper.get(img_url, stream=True, headers=headers, timeout=60)
# 5. Use proxies, verify=False, and new timeout
response = scraper.get(img_url, stream=True, headers=headers, timeout=img_timeout, proxies=self.proxies, verify=False)
response.raise_for_status()
with open(filepath, 'wb') as f:
@@ -125,7 +138,7 @@ class AllcomicDownloadThread(QThread):
grand_total_skip += 1
self.overall_progress_signal.emit(total_files_in_chapter, i + 1)
time.sleep(0.5) # Increased delay between images for this site
time.sleep(0.5)
if self._check_pause(): break

View File

@@ -2,8 +2,8 @@ import os
import time
import requests
import re
import random # Needed for random delays
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, wait
from PyQt5.QtCore import QThread, pyqtSignal
from ...core.deviantart_client import DeviantArtClient
from ...utils.file_utils import clean_folder_name
@@ -14,28 +14,29 @@ class DeviantArtDownloadThread(QThread):
overall_progress_signal = pyqtSignal(int, int)
finished_signal = pyqtSignal(int, int, bool, list)
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None):
# 1. Accept proxies in init
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None, proxies=None):
super().__init__(parent)
self.url = url
self.output_dir = output_dir
self.pause_event = pause_event
self.cancellation_event = cancellation_event
# --- PASS LOGGER TO CLIENT ---
# This ensures client logs go to the UI, not just the black console window
self.client = DeviantArtClient(logger_func=self.progress_signal.emit)
self.proxies = proxies # Store proxies
self.parent_app = parent
self.download_count = 0
self.skip_count = 0
# --- THREAD SETTINGS ---
self.max_threads = 10
def run(self):
self.client = DeviantArtClient(logger_func=self.progress_signal.emit, proxies=self.proxies)
if self.proxies:
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
else:
self.progress_signal.emit(" 🌍 Network: Direct Connection")
self.progress_signal.emit("=" * 40)
self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}")
self.progress_signal.emit(f" Using {self.max_threads} parallel threads.")
try:
if not self.client.authenticate():
@@ -91,26 +92,25 @@ class DeviantArtDownloadThread(QThread):
if not os.path.exists(base_folder):
os.makedirs(base_folder, exist_ok=True)
with ThreadPoolExecutor(max_workers=self.max_threads) as executor:
while has_more:
while has_more:
if self._check_pause_cancel(): break
data = self.client.get_gallery_folder(username, offset=offset)
results = data.get('results', [])
has_more = data.get('has_more', False)
offset = data.get('next_offset')
if not results: break
for deviation in results:
if self._check_pause_cancel(): break
data = self.client.get_gallery_folder(username, offset=offset)
results = data.get('results', [])
has_more = data.get('has_more', False)
offset = data.get('next_offset')
if not results: break
futures = []
for deviation in results:
if self._check_pause_cancel(): break
future = executor.submit(self._process_deviation_task, deviation, base_folder)
futures.append(future)
wait(futures)
self._process_deviation_task(deviation, base_folder)
time.sleep(1)
# 4. FIX 429: Add a small random delay between items
# This prevents hammering the API 24 times in a single second.
time.sleep(random.uniform(0.5, 1.2))
time.sleep(1)
def _process_deviation_task(self, deviation, base_folder):
if self._check_pause_cancel(): return
@@ -173,7 +173,7 @@ class DeviantArtDownloadThread(QThread):
final_filename = f"{clean_folder_name(new_name)}{ext}"
except Exception as e:
self.progress_signal.emit(f" ⚠️ Renaming failed ({e}), using default.")
pass
save_dir = override_dir if override_dir else self.output_dir
if not os.path.exists(save_dir):
@@ -189,7 +189,11 @@ class DeviantArtDownloadThread(QThread):
try:
self.progress_signal.emit(f" ⬇️ Downloading: {final_filename}")
with requests.get(file_url, stream=True, timeout=30) as r:
# 5. Determine smart timeout for files
timeout_val = (30, 120) if self.proxies else 30
# 6. Use proxies and verify=False
with requests.get(file_url, stream=True, timeout=timeout_val, proxies=self.proxies, verify=False) as r:
r.raise_for_status()
with open(filepath, 'wb') as f:

View File

@@ -1,6 +1,6 @@
import os
import time
import cloudscraper
import requests
from PyQt5.QtCore import QThread, pyqtSignal
from ...utils.file_utils import clean_folder_name
@@ -17,68 +17,78 @@ class NhentaiDownloadThread(QThread):
EXTENSION_MAP = {'j': 'jpg', 'p': 'png', 'g': 'gif', 'w': 'webp' }
# 1. Update init to initialize self.proxies
def __init__(self, gallery_data, output_dir, parent=None):
super().__init__(parent)
self.gallery_data = gallery_data
self.output_dir = output_dir
self.is_cancelled = False
self.proxies = None # Placeholder, will be injected by main_window
def run(self):
# 2. Log Proxy Usage
if self.proxies:
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
else:
self.progress_signal.emit(" 🌍 Network: Direct Connection (No Proxy)")
title = self.gallery_data.get("title", {}).get("english", f"gallery_{self.gallery_data.get('id')}")
gallery_id = self.gallery_data.get("id")
media_id = self.gallery_data.get("media_id")
pages_info = self.gallery_data.get("pages", [])
folder_name = clean_folder_name(title)
gallery_path = os.path.join(self.output_dir, folder_name)
save_path = os.path.join(self.output_dir, folder_name)
try:
os.makedirs(gallery_path, exist_ok=True)
except OSError as e:
self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
os.makedirs(save_path, exist_ok=True)
self.progress_signal.emit(f" Saving to: {folder_name}")
except Exception as e:
self.progress_signal.emit(f" ❌ Error creating directory: {e}")
self.finished_signal.emit(0, len(pages_info), False)
return
self.progress_signal.emit(f"⬇️ Downloading '{title}' to folder '{folder_name}'...")
scraper = cloudscraper.create_scraper()
download_count = 0
skip_count = 0
total_pages = len(pages_info)
# 3. Use requests.Session instead of cloudscraper
scraper = requests.Session()
# 4. Smart timeout logic
img_timeout = (30, 120) if self.proxies else 60
for i, page_data in enumerate(pages_info):
if self.is_cancelled:
break
page_num = i + 1
if self.is_cancelled: break
ext_char = page_data.get('t', 'j')
extension = self.EXTENSION_MAP.get(ext_char, 'jpg')
relative_path = f"/galleries/{media_id}/{page_num}.{extension}"
local_filename = f"{page_num:03d}.{extension}"
filepath = os.path.join(gallery_path, local_filename)
file_ext = self.EXTENSION_MAP.get(page_data.get('t'), 'jpg')
local_filename = f"{i+1:03d}.{file_ext}"
filepath = os.path.join(save_path, local_filename)
if os.path.exists(filepath):
self.progress_signal.emit(f" -> Skip (Exists): {local_filename}")
self.progress_signal.emit(f" Skipping {local_filename} (already exists).")
skip_count += 1
continue
download_successful = False
# Try servers until one works
for server in self.IMAGE_SERVERS:
if self.is_cancelled:
break
if self.is_cancelled: break
# Construct URL: server/galleries/media_id/page_num.ext
full_url = f"{server}/galleries/{media_id}/{i+1}.{file_ext}"
full_url = f"{server}{relative_path}"
try:
self.progress_signal.emit(f" Downloading page {page_num}/{len(pages_info)} from {server} ...")
self.progress_signal.emit(f" Downloading page {i+1}/{total_pages}...")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': f'https://nhentai.net/g/{gallery_id}/'
}
response = scraper.get(full_url, headers=headers, timeout=60, stream=True)
# 5. Add proxies, verify=False, and timeout
response = scraper.get(full_url, headers=headers, timeout=img_timeout, stream=True, proxies=self.proxies, verify=False)
if response.status_code == 200:
with open(filepath, 'wb') as f:
@@ -86,12 +96,14 @@ class NhentaiDownloadThread(QThread):
f.write(chunk)
download_count += 1
download_successful = True
break
break # Stop trying servers
else:
self.progress_signal.emit(f" -> {server} returned status {response.status_code}. Trying next server...")
# self.progress_signal.emit(f" -> {server} returned status {response.status_code}...")
pass
except Exception as e:
self.progress_signal.emit(f" -> {server} failed to connect or timed out: {e}. Trying next server...")
# self.progress_signal.emit(f" -> {server} failed: {e}")
pass
if not download_successful:
self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.")

View File

@@ -5,10 +5,11 @@ import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtGui import QIntValidator # <--- NEW: Added for Port validation
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
QTabWidget, QWidget, QFileDialog # Added QFileDialog
QTabWidget, QWidget, QFileDialog
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
@@ -21,7 +22,9 @@ from ...config.constants import (
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
DATE_PREFIX_FORMAT_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY,
PROXY_ENABLED_KEY, PROXY_HOST_KEY, PROXY_PORT_KEY,
PROXY_USERNAME_KEY, PROXY_PASSWORD_KEY
)
from ...services.updater import UpdateChecker, UpdateDownloader
@@ -118,16 +121,15 @@ class FutureSettingsDialog(QDialog):
super().__init__(parent)
self.parent_app = parent_app_ref
self.setModal(True)
self.update_downloader_thread = None # To keep a reference
self.update_downloader_thread = None
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
# Use a more balanced aspect ratio
scale_factor = screen_height / 1000.0
base_min_w, base_min_h = 480, 420 # Wider, less tall
base_min_w, base_min_h = 550, 450 # <--- TWEAK: Slightly increased width for better 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)
@@ -135,6 +137,9 @@ class FutureSettingsDialog(QDialog):
self._init_ui()
self._retranslate_ui()
self._apply_theme()
# <--- NEW: Load proxy settings on init
self._load_proxy_settings()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
@@ -147,14 +152,16 @@ class FutureSettingsDialog(QDialog):
# --- Create Tabs ---
self.display_tab = QWidget()
self.downloads_tab = QWidget()
self.network_tab = QWidget() # <--- NEW: Network Tab
self.updates_tab = QWidget()
# Add tabs to the widget
self.tab_widget.addTab(self.display_tab, "Display")
self.tab_widget.addTab(self.downloads_tab, "Downloads")
self.tab_widget.addTab(self.network_tab, "Proxy/Network") # <--- NEW
self.tab_widget.addTab(self.updates_tab, "Updates")
# --- Populate Display Tab ---
# [Display Tab Code (Unchanged) ...]
display_tab_layout = QVBoxLayout(self.display_tab)
self.display_group_box = QGroupBox()
display_layout = QGridLayout(self.display_group_box)
@@ -184,9 +191,9 @@ class FutureSettingsDialog(QDialog):
display_layout.addWidget(self.resolution_combo_box, 3, 1)
display_tab_layout.addWidget(self.display_group_box)
display_tab_layout.addStretch(1) # Push content to the top
display_tab_layout.addStretch(1)
# --- Populate Downloads Tab ---
# [Downloads Tab Code (Unchanged) ...]
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
self.download_settings_group_box = QGroupBox()
download_settings_layout = QGridLayout(self.download_settings_group_box)
@@ -217,7 +224,6 @@ class FutureSettingsDialog(QDialog):
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
# --- START: Add new Load/Save buttons ---
settings_file_layout = QHBoxLayout()
self.load_settings_button = QPushButton()
self.save_settings_button = QPushButton()
@@ -225,18 +231,72 @@ class FutureSettingsDialog(QDialog):
settings_file_layout.addWidget(self.save_settings_button)
settings_file_layout.addStretch(1)
# Add this new layout to the grid
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2) # Row 5, span 2 cols
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2)
# Connect signals
self.load_settings_button.clicked.connect(self._handle_load_settings)
self.save_settings_button.clicked.connect(self._handle_save_settings)
# --- END: Add new Load/Save buttons ---
downloads_tab_layout.addWidget(self.download_settings_group_box)
downloads_tab_layout.addStretch(1) # Push content to the top
downloads_tab_layout.addStretch(1)
# --- Populate Updates Tab ---
# --- START: Network Tab (NEW) ---
network_tab_layout = QVBoxLayout(self.network_tab)
self.proxy_group_box = QGroupBox()
proxy_layout = QGridLayout(self.proxy_group_box)
# Enable Checkbox
self.proxy_enabled_checkbox = QCheckBox()
self.proxy_enabled_checkbox.stateChanged.connect(self._proxy_setting_changed)
proxy_layout.addWidget(self.proxy_enabled_checkbox, 0, 0, 1, 2)
# Proxy Type Dropdown
self.proxy_type_label = QLabel("Proxy Type:")
self.proxy_type_combo = QComboBox()
self.proxy_type_combo.addItems(["HTTP", "SOCKS4", "SOCKS5"])
self.proxy_type_combo.currentIndexChanged.connect(self._proxy_setting_changed)
proxy_layout.addWidget(self.proxy_type_label, 1, 0)
proxy_layout.addWidget(self.proxy_type_combo, 1, 1)
# Host / IP
self.proxy_host_label = QLabel()
self.proxy_host_input = QLineEdit()
self.proxy_host_input.setPlaceholderText("127.0.0.1")
self.proxy_host_input.editingFinished.connect(self._proxy_setting_changed)
proxy_layout.addWidget(self.proxy_host_label, 2, 0) # Changed row to 2
proxy_layout.addWidget(self.proxy_host_input, 2, 1)
# Port
self.proxy_port_label = QLabel()
self.proxy_port_input = QLineEdit()
self.proxy_port_input.setPlaceholderText("8080")
self.proxy_port_input.setValidator(QIntValidator(1, 65535, self)) # Only numbers
self.proxy_port_input.editingFinished.connect(self._proxy_setting_changed)
proxy_layout.addWidget(self.proxy_port_label, 3, 0)
proxy_layout.addWidget(self.proxy_port_input, 3, 1)
# Username
self.proxy_user_label = QLabel()
self.proxy_user_input = QLineEdit()
self.proxy_user_input.setPlaceholderText("(Optional)")
self.proxy_user_input.editingFinished.connect(self._proxy_setting_changed)
proxy_layout.addWidget(self.proxy_user_label, 4, 0)
proxy_layout.addWidget(self.proxy_user_input, 4, 1)
# Password
self.proxy_pass_label = QLabel()
self.proxy_pass_input = QLineEdit()
self.proxy_pass_input.setPlaceholderText("(Optional)")
self.proxy_pass_input.setEchoMode(QLineEdit.Password) # Mask input
self.proxy_pass_input.editingFinished.connect(self._proxy_setting_changed)
proxy_layout.addWidget(self.proxy_pass_label, 5, 0)
proxy_layout.addWidget(self.proxy_pass_input, 5, 1)
network_tab_layout.addWidget(self.proxy_group_box)
network_tab_layout.addStretch(1)
# --- END: Network Tab (NEW) ---
# [Updates Tab Code (Unchanged) ...]
updates_tab_layout = QVBoxLayout(self.updates_tab)
self.update_group_box = QGroupBox()
update_layout = QGridLayout(self.update_group_box)
@@ -249,7 +309,7 @@ class FutureSettingsDialog(QDialog):
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
updates_tab_layout.addWidget(self.update_group_box)
updates_tab_layout.addStretch(1) # Push content to the top
updates_tab_layout.addStretch(1)
# --- OK Button (outside tabs) ---
button_layout = QHBoxLayout()
@@ -266,16 +326,17 @@ class FutureSettingsDialog(QDialog):
# --- Tab Titles ---
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display"))
self.tab_widget.setTabText(1, self._tr("settings_tab_downloads", "Downloads"))
self.tab_widget.setTabText(2, self._tr("settings_tab_updates", "Updates"))
self.tab_widget.setTabText(2, self._tr("settings_tab_network", "Proxy/Network")) # <--- NEW
self.tab_widget.setTabText(3, self._tr("settings_tab_updates", "Updates"))
# --- Display Tab ---
# [Display Tab (Unchanged) ...]
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
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:"))
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
# --- Downloads Tab ---
# [Downloads Tab (Unchanged) ...]
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
@@ -294,32 +355,112 @@ class FutureSettingsDialog(QDialog):
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions."))
# --- START: Add new button text ---
self.load_settings_button.setText(self._tr("load_settings_button", "Load Settings..."))
self.load_settings_button.setToolTip(self._tr("load_settings_tooltip", "Load all download settings from a .json file."))
self.save_settings_button.setText(self._tr("save_settings_button", "Save Settings..."))
self.save_settings_button.setToolTip(self._tr("save_settings_tooltip", "Save all current download settings to a .json file."))
# --- END: Add new button text ---
# --- Updates Tab ---
# --- START: Network Tab (NEW) ---
self.proxy_group_box.setTitle(self._tr("proxy_settings_group_title", "Proxy Configuration"))
self.proxy_enabled_checkbox.setText(self._tr("proxy_enabled_label", "Enable Proxy"))
self.proxy_host_label.setText(self._tr("proxy_host_label", "Host / IP:"))
self.proxy_port_label.setText(self._tr("proxy_port_label", "Port:"))
self.proxy_user_label.setText(self._tr("proxy_user_label", "Username (Optional):"))
self.proxy_pass_label.setText(self._tr("proxy_pass_label", "Password (Optional):"))
# --- END: Network Tab (NEW) ---
# [Updates Tab (Unchanged) ...]
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
current_version = self.parent_app.windowTitle().split(' v')[-1]
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
# --- General ---
self._update_theme_toggle_button_text()
self.ok_button.setText(self._tr("ok_button", "OK"))
# --- Load Data ---
self._populate_display_combo_boxes()
self._populate_language_combo_box()
self._populate_post_download_action_combo()
self._load_date_prefix_format()
self._load_checkbox_states()
# --- START: New Proxy Logic ---
def _load_proxy_settings(self):
"""Loads proxy settings from QSettings into the UI."""
# Block signals to prevent triggering auto-save while loading
self.proxy_enabled_checkbox.blockSignals(True)
self.proxy_type_combo.blockSignals(True) # <--- NEW
self.proxy_host_input.blockSignals(True)
self.proxy_port_input.blockSignals(True)
self.proxy_user_input.blockSignals(True)
self.proxy_pass_input.blockSignals(True)
# Load values
enabled = self.parent_app.settings.value(PROXY_ENABLED_KEY, False, type=bool)
proxy_type = self.parent_app.settings.value("proxy_type", "HTTP", type=str) # <--- NEW
host = self.parent_app.settings.value(PROXY_HOST_KEY, "", type=str)
port = self.parent_app.settings.value(PROXY_PORT_KEY, "", type=str)
user = self.parent_app.settings.value(PROXY_USERNAME_KEY, "", type=str)
password = self.parent_app.settings.value(PROXY_PASSWORD_KEY, "", type=str)
# Apply values to UI
self.proxy_enabled_checkbox.setChecked(enabled)
# <--- NEW: Set the dropdown selection
index = self.proxy_type_combo.findText(proxy_type)
if index >= 0:
self.proxy_type_combo.setCurrentIndex(index)
else:
self.proxy_type_combo.setCurrentIndex(0) # Default to first item if not found
self.proxy_host_input.setText(host)
self.proxy_port_input.setText(port)
self.proxy_user_input.setText(user)
self.proxy_pass_input.setText(password)
self._update_proxy_fields_state(enabled)
# Unblock signals
self.proxy_enabled_checkbox.blockSignals(False)
self.proxy_type_combo.blockSignals(False) # <--- NEW
self.proxy_host_input.blockSignals(False)
self.proxy_port_input.blockSignals(False)
self.proxy_user_input.blockSignals(False)
self.proxy_pass_input.blockSignals(False)
def _proxy_setting_changed(self):
"""Saves the current proxy UI state to QSettings."""
enabled = self.proxy_enabled_checkbox.isChecked()
proxy_type = self.proxy_type_combo.currentText() # <--- NEW
host = self.proxy_host_input.text().strip()
port = self.proxy_port_input.text().strip()
user = self.proxy_user_input.text().strip()
password = self.proxy_pass_input.text().strip()
self.parent_app.settings.setValue(PROXY_ENABLED_KEY, enabled)
self.parent_app.settings.setValue("proxy_type", proxy_type) # <--- NEW
self.parent_app.settings.setValue(PROXY_HOST_KEY, host)
self.parent_app.settings.setValue(PROXY_PORT_KEY, port)
self.parent_app.settings.setValue(PROXY_USERNAME_KEY, user)
self.parent_app.settings.setValue(PROXY_PASSWORD_KEY, password)
self.parent_app.settings.sync()
self._update_proxy_fields_state(enabled)
# Optional: Notify main app that network settings changed if needed
# self.parent_app.reload_proxy_settings()
def _update_proxy_fields_state(self, enabled):
"""Enables or disables input fields based on the checkbox."""
self.proxy_type_combo.setEnabled(enabled)
self.proxy_host_input.setEnabled(enabled)
self.proxy_port_input.setEnabled(enabled)
self.proxy_user_input.setEnabled(enabled)
self.proxy_pass_input.setEnabled(enabled)
# --- END: New Proxy Logic ---
def _check_for_updates(self):
self.check_update_button.setEnabled(False)
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))

View File

@@ -73,7 +73,6 @@ class HelpGuideDialog(QDialog):
<li>fap-nation.org/</li>
<li>Discord</li>
<li>allporncomic.com</li>
<li>allporncomic.com</li>
<li>hentai2read.com</li>
<li>mangadex.org</li>
<li>Simpcity</li>
@@ -279,6 +278,46 @@ class HelpGuideDialog(QDialog):
</ul>
"""),
("Add to Queue",
"""
<p>This feature allows you to queue up multiple distinct downloads with different settings and run them all sequentially.</p>
<h3 style='color: #E0E0E0;'>Step 1: Prepare the Download</h3>
<p>Before clicking add, configure the download exactly how you want it processed for this specific link:</p>
<ul>
<li><b>Select Directory:</b> Choose where you want the files to go.</li>
<li><b>Configure Options:</b> Check/uncheck boxes (e.g., "Separate Folders", "Use Cookie", "Manga Mode").</li>
<li><b>Paste URL:</b> Enter the link for the creator or post you want to download.</li>
</ul>
<h3 style='color: #E0E0E0;'>Step 2: Add to Queue</h3>
<ol>
<li>Click the <b>Add to Queue</b> button (located near the Start Download).</li>
<li><b>Confirmation:</b> You will see a popup message and the log will print <code>✅ Job added to queue</code>.</li>
<li>The URL box will clear, allowing you to immediately paste the next link.</li>
</ol>
<h3 style='color: #E0E0E0;'>Step 3: Repeat & Start</h3>
<p>You can repeat steps 1 and 2 as many times as you like. You can even change settings (like the download folder) between adds; the queue remembers the specific settings for each individual link.</p>
<p>To start processing the queue:</p>
<ol>
<li>In the Link Input box, type exactly: <code>start queue</code></li>
<li>The main "Start Download" button will change to <b>"🚀 Execute Queue"</b>.</li>
<li>Click that button to begin.</li>
</ol>
<h3 style='color: #E0E0E0;'>Processing Behavior</h3>
<p>Once started, the app will lock the UI, load the first job, download it until finished, and automatically move to the next until the queue is empty.</p>
<h3 style='color: #E0E0E0;'>Special Case: Creator Selection Popup</h3>
<p>If you use the <b>Creator Selection</b> popup (the 🎨 button):</p>
<ul>
<li>Select multiple creators in that popup and click <b>"Queue Selected"</b>.</li>
<li>The app internally adds them to a temporary list.</li>
<li>When you click the main <b>"Add to Queue"</b> button on the main window, it will detect that list and automatically bulk-create job files for all the creators you selected.</li>
</ul>
"""),
("Special Commands",
"""
<p>You can add special commands to the <b>"Filter by Character(s)"</b> input field to change download behavior for a single task. Commands are keywords wrapped in square brackets <code>[]</code>.</p>
@@ -450,7 +489,16 @@ class HelpGuideDialog(QDialog):
("Utility & Advanced Options",
"""
<p>These features provide advanced control over your downloads, sessions, and application settings.</p>
<h3 style='color: #E0E0E0;'>🛡️ Proxy Support </h3>
<p>You can now configure a proxy to bypass region blocks or ISP restrictions (e.g., for AllComic or Nhentai).</p>
<p>Go to <b>Settings ⚙️ > Proxy Tab</b> to set it up:</p>
<ul>
<li><b>Protocols:</b> Full support for <b>HTTP</b>, <b>SOCKS4</b>, and <b>SOCKS5</b>.</li>
<li><b>Authentication:</b> Supports username and password for private proxies.</li>
<li><b>Global Effect:</b> Once enabled, all app connections (including API fetches and file downloads) will route through this proxy.</li>
</ul>
<h3 style='color: #E0E0E0;'>Use Cookie</h3>
<p>This is essential for downloading from sites that require a login (like <b>SimpCity</b> or accessing your <b>favorites</b> on Kemono/Coomer). You can either:</p>
<ul>
@@ -484,6 +532,7 @@ class HelpGuideDialog(QDialog):
<li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
</ul>
</li>
<li><b>Proxy Tab:</b> Configure HTTP/SOCKS proxies and authentication.</li>
<li><b>Updates Tab:</b> Check for and install new application updates.</li>
</ul>
@@ -605,7 +654,8 @@ class HelpGuideDialog(QDialog):
main_layout.addLayout(content_layout, 1)
self.nav_list = QListWidget()
self.nav_list.setFixedWidth(int(220 * scale))
# Increased width to prevent scrollbar overlap
self.nav_list.setFixedWidth(int(280 * scale))
# Styles are now set in the __init__ method
content_layout.addWidget(self.nav_list)

View File

@@ -28,8 +28,8 @@ class UpdateCheckDialog(QDialog):
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
self._default_checkbox_tooltip = (
"If checked, the settings from the selected profile will be loaded into the main window.\n"
"You can then modify them. When you start the download, the new settings will be saved to the profile."
"If checked, the settings fields will be unlocked and editable.\n"
"If unchecked, settings will still load, but in 'Read-Only' mode."
)
self._init_ui()
@@ -65,13 +65,17 @@ class UpdateCheckDialog(QDialog):
self.list_widget.itemChanged.connect(self._handle_item_changed)
layout.addWidget(self.list_widget)
# --- NEW: Checkbox to Load Settings ---
self.load_settings_checkbox = QCheckBox("Load profile settings into UI (Edit Settings)")
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
layout.addWidget(self.load_settings_checkbox)
# Renamed text to reflect new behavior
self.edit_settings_checkbox = QCheckBox("Enable Editing (Unlock Settings)")
self.edit_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
# Checked by default as requested
self.edit_settings_checkbox.setChecked(True)
layout.addWidget(self.edit_settings_checkbox)
# -------------------------------------
# --- All Buttons in One Horizontal Layout ---
button_layout = QHBoxLayout()
button_layout.setSpacing(6) # small even spacing between all buttons
@@ -110,7 +114,8 @@ class UpdateCheckDialog(QDialog):
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
self.load_settings_checkbox.setText(self._tr("update_check_load_settings_checkbox", "Load profile settings into UI (Edit Settings)"))
# Updated translation key and default text
self.edit_settings_checkbox.setText(self._tr("update_check_enable_editing_checkbox", "Enable Editing (Unlock Settings)"))
def _load_profiles(self):
"""Loads all .json files from the creator_profiles directory as checkable items."""
@@ -133,7 +138,6 @@ class UpdateCheckDialog(QDialog):
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
# Basic validation to ensure it's a valid profile
if 'creator_url' in data and 'processed_post_ids' in data:
creator_name = os.path.splitext(filename)[0]
profiles_found.append({'name': creator_name, 'data': data})
@@ -147,7 +151,6 @@ class UpdateCheckDialog(QDialog):
for profile_info in profiles_found:
item = QListWidgetItem(profile_info['name'])
item.setData(Qt.UserRole, profile_info)
# --- Make item checkable ---
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
self.list_widget.addItem(item)
@@ -158,14 +161,13 @@ class UpdateCheckDialog(QDialog):
self.check_button.setEnabled(False)
self.select_all_button.setEnabled(False)
self.deselect_all_button.setEnabled(False)
self.load_settings_checkbox.setEnabled(False)
self.edit_settings_checkbox.setEnabled(False)
def _toggle_all_checkboxes(self):
"""Handles Select All and Deselect All button clicks."""
sender = self.sender()
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
# Block signals to prevent triggering _handle_item_changed repeatedly
self.list_widget.blockSignals(True)
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
@@ -173,13 +175,12 @@ class UpdateCheckDialog(QDialog):
item.setCheckState(check_state)
self.list_widget.blockSignals(False)
# Manually trigger the update once after batch change
self._handle_item_changed(None)
def _handle_item_changed(self, item):
"""
Monitors how many items are checked.
If more than 1 item is checked, disable the 'Load Settings' checkbox.
If more than 1 item is checked, disable the 'Enable Editing' checkbox.
"""
checked_count = 0
for i in range(self.list_widget.count()):
@@ -187,15 +188,15 @@ class UpdateCheckDialog(QDialog):
checked_count += 1
if checked_count > 1:
self.load_settings_checkbox.setChecked(False)
self.load_settings_checkbox.setEnabled(False)
self.load_settings_checkbox.setToolTip(
self.edit_settings_checkbox.setChecked(False)
self.edit_settings_checkbox.setEnabled(False)
self.edit_settings_checkbox.setToolTip(
self._tr("update_check_multi_selection_warning",
"Editing settings is disabled when multiple profiles are selected.")
)
else:
self.load_settings_checkbox.setEnabled(True)
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
self.edit_settings_checkbox.setEnabled(True)
self.edit_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
def on_check_selected(self):
"""Handles the 'Check Selected' button click."""
@@ -221,6 +222,18 @@ class UpdateCheckDialog(QDialog):
return self.selected_profiles_list
def should_load_into_ui(self):
"""Returns True if the 'Load settings into UI' checkbox is checked."""
# Only return True if it's enabled and checked (double safety)
return self.load_settings_checkbox.isEnabled() and self.load_settings_checkbox.isChecked()
"""
Returns True if the settings SHOULD be loaded into the UI.
NEW LOGIC: Returns True if exactly ONE profile is selected.
It does NOT care about the checkbox state anymore, because we want
to load settings even if the user can't edit them.
"""
return len(self.selected_profiles_list) == 1
def should_enable_editing(self):
"""
NEW METHOD: Returns True if the user is allowed to edit the settings.
This is linked to the checkbox.
"""
return self.edit_settings_checkbox.isEnabled() and self.edit_settings_checkbox.isChecked()

View File

@@ -346,7 +346,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v7.8.0")
self.setWindowTitle("Kemono Downloader v7.9.0")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@@ -366,18 +366,14 @@ class DownloaderApp (QWidget ):
def add_current_settings_to_queue(self):
"""Saves the current UI settings as a JSON job file with creator-specific paths."""
# --- Helper: Append Name to Path safely ---
def get_creator_specific_path(base_dir, folder_name):
if not folder_name:
return base_dir
safe_name = clean_folder_name(folder_name)
# Avoid double pathing (e.g. if base is .../Artist and we append /Artist again)
if base_dir.replace('\\', '/').rstrip('/').endswith(safe_name):
return base_dir
return os.path.join(base_dir, safe_name)
# ------------------------------------------
# --- SCENARIO 1: Items from Creator Selection (Popup) ---
if self.favorite_download_queue:
count = 0
base_settings = self._get_current_ui_settings_as_dict()
@@ -407,7 +403,7 @@ class DownloaderApp (QWidget ):
QMessageBox.warning(self, "Queue Error", "Failed to add selected items to queue.")
return
# --- SCENARIO 2: Manual URL Entry ---
url = self.link_input.text().strip()
if not url:
QMessageBox.warning(self, "Input Error", "Cannot add to queue: URL is empty.")
@@ -416,23 +412,20 @@ class DownloaderApp (QWidget ):
settings = self._get_current_ui_settings_as_dict()
settings['api_url'] = url
# Attempt to resolve name from URL + Cache (creators.json)
service, user_id, post_id = extract_post_info(url)
name_hint = "Job"
if service and user_id:
# Try to find name in your local creators cache
cache_key = (service.lower(), str(user_id))
cached_name = self.creator_name_cache.get(cache_key)
if cached_name:
# CASE A: Creator Found -> Use Creator Name
name_hint = cached_name
settings['output_dir'] = get_creator_specific_path(settings['output_dir'], cached_name)
else:
# CASE B: Creator NOT Found -> Use Post ID or User ID
# If it's a single post link, 'post_id' will have a value.
# If it's a profile link, 'post_id' is None, so we use 'user_id'.
if post_id:
folder_name = str(post_id)
else:
@@ -476,7 +469,7 @@ class DownloaderApp (QWidget ):
QMessageBox.information(self, "Queue Empty", "No job files found in appdata/jobs.")
return
# --- FIX: Clear error log at the start of the entire queue session ---
self.permanently_failed_files_for_dialog.clear()
self._update_error_button_count()
# -------------------------------------------------------------------
@@ -843,7 +836,32 @@ class DownloaderApp (QWidget ):
settings['add_info_in_pdf'] = self.add_info_in_pdf_setting # Save to settings dict
settings['keep_duplicates_mode'] = self.keep_duplicates_mode
settings['keep_duplicates_limit'] = self.keep_duplicates_limit
settings['proxy_enabled'] = self.settings.value(PROXY_ENABLED_KEY, False, type=bool)
settings['proxy_host'] = self.settings.value(PROXY_HOST_KEY, "", type=str)
settings['proxy_port'] = self.settings.value(PROXY_PORT_KEY, "", type=str)
settings['proxy_username'] = self.settings.value(PROXY_USERNAME_KEY, "", type=str)
settings['proxy_password'] = self.settings.value(PROXY_PASSWORD_KEY, "", type=str)
proxy_type_str = self.settings.value("proxy_type", "HTTP", type=str)
settings['proxies'] = None
if settings['proxy_enabled'] and settings['proxy_host'] and settings['proxy_port']:
# Determine correct scheme
scheme = "http"
if proxy_type_str == "SOCKS5":
scheme = "socks5h" # 'socks5h' forces remote DNS resolution (safer/better for bypassing)
elif proxy_type_str == "SOCKS4":
scheme = "socks4"
# Build URL string
if settings['proxy_username'] and settings['proxy_password']:
proxy_str = f"{scheme}://{settings['proxy_username']}:{settings['proxy_password']}@{settings['proxy_host']}:{settings['proxy_port']}"
else:
proxy_str = f"{scheme}://{settings['proxy_host']}:{settings['proxy_port']}"
settings['proxies'] = {'http': proxy_str, 'https': proxy_str}
return settings
@@ -2950,6 +2968,25 @@ class DownloaderApp (QWidget ):
else:
self.log_signal.emit(" Link export was cancelled by the user.")
def _set_inputs_read_only(self, read_only):
"""Disables input fields (Read-Only mode) but keeps action buttons enabled."""
# List of widgets to disable in Read-Only mode
widgets_to_lock = [
self.link_input, self.dir_input, self.character_input,
self.skip_words_input, self.remove_from_filename_input,
self.custom_folder_input, self.cookie_text_input,
self.thread_count_input, self.start_page_input, self.end_page_input,
self.use_subfolders_checkbox, self.use_subfolder_per_post_checkbox,
self.skip_zip_checkbox, self.download_thumbnails_checkbox,
self.compress_images_checkbox, self.scan_content_images_checkbox,
self.use_cookie_checkbox, self.manga_mode_checkbox,
self.radio_all, self.radio_images, self.radio_videos,
self.char_filter_scope_toggle_button, self.skip_scope_toggle_button
]
for widget in widgets_to_lock:
if widget:
widget.setEnabled(not read_only)
def get_filter_mode (self ):
if self.radio_more and self.radio_more.isChecked():
@@ -3218,7 +3255,6 @@ class DownloaderApp (QWidget ):
if self.single_pdf_setting:
self.use_subfolder_per_post_checkbox.setChecked(False)
# --- Logging ---
self.log_signal.emit(f" 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
if is_any_pdf_mode:
status_single = "Enabled" if self.single_pdf_setting else "Disabled"
@@ -3227,19 +3263,18 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" ↳ Multithreading disabled for PDF export.")
else:
# --- User clicked Cancel: Revert to default ---
self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.")
if hasattr(self, 'radio_all'):
self.radio_all.setChecked(True)
# Case 2: Switched AWAY from the "More" button (e.g., clicked 'Images' or 'All')
elif button != self.radio_more and checked:
self.radio_more.setText("More")
self.more_filter_scope = None
self.single_pdf_setting = False
self.add_info_in_pdf_setting = False # Reset setting
# Restore enabled states for options that PDF mode might have disabled
if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode() # Re-check manga logic
@@ -4158,9 +4193,12 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False
# START of the new refactored block
service, id1, id2 = extract_post_info(api_url)
# [NEW] Get proxy settings immediately
ui_settings = self._get_current_ui_settings_as_dict()
proxies_to_use = ui_settings.get('proxies')
specialized_thread = create_downloader_thread(
main_app=self,
api_url=api_url,
@@ -4183,18 +4221,18 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(False)
self.download_thread = specialized_thread
# [NEW] Inject proxies into the thread manually
if hasattr(self.download_thread, 'proxies'):
self.download_thread.proxies = proxies_to_use
self._connect_specialized_thread_signals(self.download_thread)
self.download_thread.start()
self._update_button_states_and_connections()
return True
# END of the new refactored block
if not service or not id1:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
user_id, post_id_from_url = id1, id2
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue:
self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}")
self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.")
@@ -4627,6 +4665,14 @@ class DownloaderApp (QWidget ):
if should_use_multithreading_for_posts:
log_messages.append(f" Number of Post Worker Threads: {effective_num_post_workers}")
proxy_enabled_log = self.settings.value(PROXY_ENABLED_KEY, False, type=bool)
if proxy_enabled_log:
p_host = self.settings.value(PROXY_HOST_KEY, "")
p_port = self.settings.value(PROXY_PORT_KEY, "")
log_messages.append(f" Proxy: Enabled ({p_host}:{p_port})")
else:
log_messages.append(f" Proxy: Disabled")
if domain_override_command:
self.log_signal.emit(f" Domain Override Active: Will probe for the correct 'n*' subdomain on the '.{domain_override_command}' domain for each file.")
@@ -4639,7 +4685,7 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(False)
from src.config.constants import FOLDER_NAME_STOP_WORDS
current_proxies = self._get_current_ui_settings_as_dict().get('proxies')
args_template = {
'api_url_input': api_url,
'download_root': effective_output_dir_for_run,
@@ -4715,7 +4761,8 @@ class DownloaderApp (QWidget ):
'fetch_first': fetch_first_enabled,
'sfp_threshold': download_commands.get('sfp_threshold'),
'handle_unknown_mode': handle_unknown_command,
'add_info_in_pdf': self.add_info_in_pdf_setting,
'add_info_in_pdf': self.add_info_in_pdf_setting,
'proxies': current_proxies
}
args_template['override_output_dir'] = override_output_dir
@@ -4741,7 +4788,8 @@ class DownloaderApp (QWidget ):
'app_base_dir': app_base_dir_for_cookies,
'manga_filename_style_for_sort_check': self.manga_filename_style,
'processed_post_ids': processed_post_ids_for_this_run,
'fetch_all_first': True
'fetch_all_first': True,
'proxies': self._get_current_ui_settings_as_dict().get('proxies')
}
self.download_thread = threading.Thread(target=self._run_fetch_only_thread, args=(fetch_thread_args,), daemon=True)
@@ -5066,8 +5114,54 @@ class DownloaderApp (QWidget ):
self.is_ready_to_download_batch_update = True
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
self.set_ui_enabled(True) # Re-enable UI
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
self.set_ui_enabled(True) # Re-enable UI first
# [NEW] Apply Read-Only mode if it was selected in the dialog
if getattr(self, 'update_settings_read_only_mode', False):
self._set_inputs_read_only(True)
self._update_button_states_and_connections()
def _show_update_check_dialog(self):
"""Shows the Update Check Dialog and applies Load/Edit logic."""
if self.is_restore_pending:
QMessageBox.warning(self, "Restore Pending", "Please restore or discard the previous session first.")
return
dialog = UpdateCheckDialog(self.user_data_path, self, self)
if dialog.exec_() == QDialog.Accepted:
profiles = dialog.get_selected_profiles()
if not profiles: return
self.active_update_profiles_list = profiles
# --- LOGIC START ---
# 1. ALWAYS Load Settings if appropriate (e.g. Single Profile selected)
# The dialog now returns True for should_load_into_ui() if count == 1, regardless of checkbox
if dialog.should_load_into_ui():
# Load settings from the FIRST selected profile
first_profile_settings = profiles[0]['data'].get('settings', {})
self._load_ui_from_settings_dict(first_profile_settings)
# 2. Check if Editing is Allowed
if dialog.should_enable_editing():
self.update_settings_read_only_mode = False
self.override_update_profile_settings = True # Use UI values for download
self.log_signal.emit(" Settings loaded in EDITABLE mode.")
else:
self.update_settings_read_only_mode = True
self.override_update_profile_settings = False # Use original JSON values (safer for Read-Only)
self.log_signal.emit(" Settings loaded in READ-ONLY mode.")
else:
# Multiple profiles or load disabled
self.update_settings_read_only_mode = False
self.override_update_profile_settings = False
# --- LOGIC END ---
self._start_batch_update_check(self.active_update_profiles_list)
def _start_download_of_batch_update(self):
"""
@@ -5097,8 +5191,7 @@ class DownloaderApp (QWidget ):
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
# 1. Define all LIVE RUNTIME arguments.
# These are taken from the current app state and are the same for all workers.
current_proxies = self._get_current_ui_settings_as_dict().get('proxies')
live_runtime_args = {
'emitter': self.worker_to_gui_queue,
'creator_name_cache': self.creator_name_cache,
@@ -5128,7 +5221,8 @@ class DownloaderApp (QWidget ):
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
'add_info_in_pdf': self.add_info_in_pdf_setting,
'add_info_in_pdf': self.add_info_in_pdf_setting,
'proxies': current_proxies,
}
# 2. Define DEFAULTS for all settings that *should* be in the profile.
@@ -5364,6 +5458,19 @@ class DownloaderApp (QWidget ):
self._update_manga_filename_style_button_text()
self._update_multipart_toggle_button_text()
if 'proxy_enabled' in settings:
self.settings.setValue(PROXY_ENABLED_KEY, settings['proxy_enabled'])
if 'proxy_host' in settings:
self.settings.setValue(PROXY_HOST_KEY, settings['proxy_host'])
if 'proxy_port' in settings:
self.settings.setValue(PROXY_PORT_KEY, settings['proxy_port'])
if 'proxy_username' in settings:
self.settings.setValue(PROXY_USERNAME_KEY, settings['proxy_username'])
if 'proxy_password' in settings:
self.settings.setValue(PROXY_PASSWORD_KEY, settings['proxy_password'])
self.settings.sync()
def start_multi_threaded_download(self, num_post_workers, **kwargs):
"""
Initializes and starts the multi-threaded download process.
@@ -5406,8 +5513,13 @@ class DownloaderApp (QWidget ):
global PostProcessorWorker, download_from_api
worker_args_template = fetcher_args['worker_args_template']
logger_func = lambda msg: self.log_signal.emit(f"[Fetcher] {msg}")
def logger_func(msg):
try:
import sip
if not sip.isdeleted(self):
self.log_signal.emit(f"[Fetcher] {msg}")
except (RuntimeError, ImportError, AttributeError):
pass # Window is gone, ignore logging
try:
# This single call now handles all fetching logic, including 'Fetch First'.
post_generator = download_from_api(
@@ -5424,7 +5536,8 @@ class DownloaderApp (QWidget ):
app_base_dir=worker_args_template.get('app_base_dir'),
manga_filename_style_for_sort_check=worker_args_template.get('manga_filename_style'),
processed_post_ids=worker_args_template.get('processed_post_ids', []),
fetch_all_first=worker_args_template.get('fetch_first', False)
fetch_all_first=worker_args_template.get('fetch_first', False),
proxies=worker_args_template.get('proxies')
)
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]