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" 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 --- # --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>" HTML_PREFIX = "<!HTML!>"
LOG_DISPLAY_LINKS = "links" LOG_DISPLAY_LINKS = "links"

View File

@@ -5,7 +5,8 @@ import time
import random import random
from urllib.parse import urlparse 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. 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. Relies on a passed-in scraper session for connection.
@@ -16,9 +17,13 @@ def get_chapter_list(scraper, series_url, logger_func):
response = None response = None
max_retries = 8 max_retries = 8
# 2. Define smart timeout logic
req_timeout = (30, 120) if proxies else 30
for attempt in range(max_retries): for attempt in range(max_retries):
try: 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() response.raise_for_status()
logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.") logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.")
break 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}") logger_func(f" [AllComic] ❌ Error parsing chapters after successful connection: {e}")
return [] 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. Fetches the comic title, chapter title, and image URLs for a single chapter page.
Relies on a passed-in scraper session for connection. Relies on a passed-in scraper session for connection.
@@ -64,9 +70,14 @@ def fetch_chapter_data(scraper, chapter_url, logger_func):
response = None response = None
max_retries = 8 max_retries = 8
# 5. Define smart timeout logic again
req_timeout = (30, 120) if proxies else 30
for attempt in range(max_retries): for attempt in range(max_retries):
try: 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() response.raise_for_status()
break break
except requests.RequestException as e: 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. 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})" log_message += f" (Attempt {attempt + 1}/{max_retries})"
logger(log_message) logger(log_message)
request_timeout = (30, 120) if proxies else (15, 60)
try: 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.raise_for_status()
response.encoding = 'utf-8' response.encoding = 'utf-8'
return response.json() 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.") 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. 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 scraper = None
try: try:
scraper = cloudscraper.create_scraper() 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() response.raise_for_status()
full_post_data = response.json() 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() 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.""" """Fetches all comments for a specific post."""
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Comment fetch operation cancelled by user.") 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}") logger(f" Fetching comments: {comments_api_url}")
try: 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.raise_for_status()
response.encoding = 'utf-8' response.encoding = 'utf-8'
return response.json() return response.json()
@@ -143,7 +152,8 @@ def download_from_api(
app_base_dir=None, app_base_dir=None,
manga_filename_style_for_sort_check=None, manga_filename_style_for_sort_check=None,
processed_post_ids=None, processed_post_ids=None,
fetch_all_first=False fetch_all_first=False,
proxies=None
): ):
parsed_input_url_for_domain = urlparse(api_url_input) parsed_input_url_for_domain = urlparse(api_url_input)
api_domain = parsed_input_url_for_domain.netloc api_domain = parsed_input_url_for_domain.netloc
@@ -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}" 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}") logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try: 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.raise_for_status()
direct_response.encoding = 'utf-8' direct_response.encoding = 'utf-8'
direct_post_data = direct_response.json() 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.") logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break break
try: try:
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
if not isinstance(posts_batch_manga, list): if not isinstance(posts_batch_manga, list):
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.") logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break break
@@ -351,7 +363,7 @@ def download_from_api(
break break
try: 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): 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}).") logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
break break

View File

@@ -11,11 +11,28 @@ class DeviantArtClient:
CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1" CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1"
BASE_API = "https://www.deviantart.com/api/v1/oauth2" 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() 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({ 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', "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
'Accept': '*/*', "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.access_token = None
self.logger = logger_func self.logger = logger_func
@@ -33,7 +50,10 @@ class DeviantArtClient:
"client_id": self.CLIENT_ID, "client_id": self.CLIENT_ID,
"client_secret": self.CLIENT_SECRET "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() resp.raise_for_status()
data = resp.json() data = resp.json()
self.access_token = data.get("access_token") self.access_token = data.get("access_token")
@@ -55,12 +75,28 @@ class DeviantArtClient:
retries = 0 retries = 0
max_retries = 4 max_retries = 4
backoff_delay = 2 backoff_delay = 2
# 4. Smart timeout
req_timeout = 30 if self.proxies_enabled else 20
while True: while True:
try: try:
resp = self.session.get(url, params=params, timeout=20) resp = self.session.get(url, params=params, timeout=req_timeout)
# Handle Token Expiration (401) # 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: if resp.status_code == 401:
self.logger(" [DeviantArt] Token expired. Refreshing...") self.logger(" [DeviantArt] Token expired. Refreshing...")
if self.authenticate(): if self.authenticate():
@@ -69,64 +105,47 @@ class DeviantArtClient:
else: else:
raise Exception("Failed to refresh token") raise Exception("Failed to refresh token")
# Handle Rate Limiting (429) if 400 <= resp.status_code < 500:
if resp.status_code == 429: resp.raise_for_status()
if retries < max_retries:
retry_after = resp.headers.get('Retry-After') if 500 <= resp.status_code < 600:
resp.raise_for_status()
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()
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: with self.log_lock:
if self.logged_waits: self.logged_waits.clear()
self.logged_waits.clear()
return resp.json() 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: except requests.exceptions.RequestException as e:
if retries < max_retries: if retries < max_retries:
# Using the lock here too to prevent connection error spam self._log_once("conn_error", f" [DeviantArt] Connection error: {e}. Retrying...")
should_log = False time.sleep(backoff_delay)
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)
retries += 1 retries += 1
continue continue
raise e 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): def get_deviation_uuid(self, url):
"""Scrapes the deviation page to find the UUID.""" """Scrapes the deviation page to find the UUID."""
try: 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) match = re.search(r'"deviationUuid":"([^"]+)"', resp.text)
if match: if match:
return match.group(1) return match.group(1)

View File

@@ -3,7 +3,7 @@ import time
import os import os
import json import json
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future from concurrent.futures import ThreadPoolExecutor, as_completed, Future, CancelledError
from .api_client import download_from_api from .api_client import download_from_api
from .workers import PostProcessorWorker from .workers import PostProcessorWorker
from ..config.constants import ( from ..config.constants import (
@@ -113,6 +113,29 @@ class DownloadManager:
self.is_running = False # Allow another session to start if needed self.is_running = False # Allow another session to start if needed
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)}) 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): 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. 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() 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', [])) profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_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: if restore_data and 'all_posts_data' in restore_data:
# This logic for session restore remains as it relies on a pre-fetched list # 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: for post_data in posts_to_process:
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
break 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 = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result) future.add_done_callback(self._handle_future_result)
self.active_futures.append(future) self.active_futures.append(future)
else: else:
# --- Streaming Logic --- # --- Streaming Logic ---
if proxies:
self._log(f" 🌐 Using Proxy: {config.get('proxy_host')}:{config.get('proxy_port')}")
post_generator = download_from_api( post_generator = download_from_api(
api_url_input=config['api_url'], api_url_input=config['api_url'],
logger=self._log, logger=self._log,
@@ -156,7 +190,8 @@ class DownloadManager:
end_page=config.get('end_page'), end_page=config.get('end_page'),
cancellation_event=self.cancellation_event, cancellation_event=self.cancellation_event,
pause_event=self.pause_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: 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] new_posts_batch = [p for p in post_batch if p.get('id') not in processed_ids]
if not new_posts_batch: if not new_posts_batch:
# Log skipped count for UI feedback if needed, already handled in api_client usually
continue continue
# Update total posts dynamically as we find them # Update total posts dynamically as we find them
self.total_posts += len(new_posts_batch) 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: for post_data in new_posts_batch:
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
break break
# Pass explicit args or config to worker # MAPPING CONFIG TO WORKER ARGS
# 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)
worker_args = self._map_config_to_worker_args(post_data, config) worker_args = self._map_config_to_worker_args(post_data, config)
worker = PostProcessorWorker(**worker_args) worker = PostProcessorWorker(**worker_args)
@@ -193,7 +221,7 @@ class DownloadManager:
future.add_done_callback(self._handle_future_result) future.add_done_callback(self._handle_future_result)
self.active_futures.append(future) 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) time.sleep(0.01)
except Exception as e: except Exception as e:
@@ -205,6 +233,9 @@ class DownloadManager:
def _map_config_to_worker_args(self, post_data, config): def _map_config_to_worker_args(self, post_data, config):
"""Helper to map the flat config dict to PostProcessorWorker arguments.""" """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__ # This mirrors the arguments in workers.py PostProcessorWorker.__init__
return { return {
'post_data': post_data, 'post_data': post_data,
@@ -221,29 +252,27 @@ class DownloadManager:
'custom_folder_name': config.get('custom_folder_name'), 'custom_folder_name': config.get('custom_folder_name'),
'compress_images': config.get('compress_images'), 'compress_images': config.get('compress_images'),
'download_thumbnails': config.get('download_thumbnails'), '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', 'user_id': config.get('user_id') or 'unknown',
'pause_event': self.pause_event, 'pause_event': self.pause_event,
'api_url_input': config.get('api_url'), 'api_url_input': config.get('api_url'),
'cancellation_event': self.cancellation_event, 'cancellation_event': self.cancellation_event,
'downloaded_files': None, # Managed per worker or global if passed 'downloaded_files': None,
'downloaded_file_hashes': None, 'downloaded_file_hashes': None,
'downloaded_files_lock': None, 'downloaded_files_lock': None,
'downloaded_file_hashes_lock': None, 'downloaded_file_hashes_lock': None,
# Add other necessary fields from config...
'manga_mode_active': config.get('manga_mode_active'), 'manga_mode_active': config.get('manga_mode_active'),
'manga_filename_style': config.get('manga_filename_style'), '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"), 'manga_custom_date_format': config.get('manga_custom_date_format', "YYYY-MM-DD"),
'use_multithreading': config.get('use_multithreading', True), 'use_multithreading': config.get('use_multithreading', True),
# Ensure defaults for others 'proxies': proxies, # <--- NEW: Pass proxies to worker
} }
def _setup_creator_profile(self, config): def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile.""" """Prepares the path and loads data for the current creator's profile."""
# Extract name logic here or assume config has it # Extract name logic here or assume config has it
# ... (Same as your existing code) self.current_creator_name_for_profile = "Unknown"
self.current_creator_name_for_profile = "Unknown" # Placeholder
# You should ideally extract name from URL or config here if available # You should ideally extract name from URL or config here if available
return {} return {}

View File

@@ -1,31 +1,35 @@
import requests import requests
import cloudscraper
import json 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. Fetches the metadata for a single nhentai gallery.
Switched to standard requests to support proxies with self-signed certs.
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.
""" """
api_url = f"https://nhentai.net/api/gallery/{gallery_id}" 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}") logger(f" Fetching nhentai gallery metadata from: {api_url}")
# 3. Smart timeout logic
req_timeout = (30, 120) if proxies else 20
try: try:
# Use the scraper to make the GET request # 4. Use requests.get with proxies, verify=False, and timeout
response = scraper.get(api_url, timeout=20) response = requests.get(api_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
if response.status_code == 404: if response.status_code == 404:
logger(f" ❌ Gallery not found (404): ID {gallery_id}") logger(f" ❌ Gallery not found (404): ID {gallery_id}")
return None 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() response.raise_for_status()
@@ -36,9 +40,9 @@ def fetch_nhentai_gallery(gallery_id, logger=print):
gallery_data['pages'] = gallery_data.pop('images')['pages'] gallery_data['pages'] = gallery_data.pop('images')['pages']
return gallery_data return gallery_data
else: 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 return None
except Exception as e: except Exception as e:
logger(f"An error occurred while fetching gallery {gallery_id}: {e}") logger(f"Error fetching nhentai metadata: {e}")
return None return None

View File

@@ -133,7 +133,8 @@ class PostProcessorWorker:
sfp_threshold=None, sfp_threshold=None,
handle_unknown_mode=False, handle_unknown_mode=False,
creator_name_cache=None, creator_name_cache=None,
add_info_in_pdf=False add_info_in_pdf=False,
proxies=None
): ):
self.post = post_data self.post = post_data
@@ -208,9 +209,8 @@ class PostProcessorWorker:
self.sfp_threshold = sfp_threshold self.sfp_threshold = sfp_threshold
self.handle_unknown_mode = handle_unknown_mode self.handle_unknown_mode = handle_unknown_mode
self.creator_name_cache = creator_name_cache self.creator_name_cache = creator_name_cache
#-- New assign --
self.add_info_in_pdf = add_info_in_pdf self.add_info_in_pdf = add_info_in_pdf
#-- New assign -- self.proxies = proxies
if self.compress_images and Image is None: if self.compress_images and Image is None:
@@ -263,7 +263,7 @@ class PostProcessorWorker:
new_url = parsed_url._replace(netloc=new_domain).geturl() new_url = parsed_url._replace(netloc=new_domain).geturl()
try: 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: if resp.status_code == 200:
return new_url return new_url
except requests.RequestException: 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')) api_original_filename_for_size_check = file_info.get('_original_name_for_log', file_info.get('name'))
try: try:
# Use a stream=True HEAD request to get headers without downloading the body # 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() head_response.raise_for_status()
content_length = head_response.headers.get('Content-Length') content_length = head_response.headers.get('Content-Length')
if content_length: if content_length:
@@ -672,7 +673,7 @@ class PostProcessorWorker:
current_url_to_try = file_url 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): 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...") 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}") self.logger(f" Retrying with new URL: {new_url}")
file_url = new_url file_url = new_url
response.close() # Close the old response 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() response.raise_for_status()
# --- REVISED AND MOVED SIZE CHECK LOGIC --- # --- REVISED AND MOVED SIZE CHECK LOGIC ---
@@ -1104,8 +1104,8 @@ class PostProcessorWorker:
'Referer': creator_page_url, 'Referer': creator_page_url,
'Accept': 'text/css' '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) 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) 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: if full_post_data:
self.logger(" ✅ Full post data fetched successfully.") self.logger(" ✅ Full post data fetched successfully.")
self.post = full_post_data 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']): 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.") 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" 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( comments_data = fetch_post_comments(
api_domain_for_comments, self.service, self.user_id, post_id, api_domain_for_comments, self.service, self.user_id, post_id,
headers, self.logger, self.cancellation_event, self.pause_event, headers, self.logger, self.cancellation_event, self.pause_event,
cookies_dict=prepare_cookies_for_request( cookies_dict=prepare_cookies_for_request(
self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger
) ),
proxies=self.proxies
) )
if comments_data: if comments_data:
self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.") self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.")
for comment_item_idx, comment_item in enumerate(comments_data): for comment_item_idx, comment_item in enumerate(comments_data):
@@ -1339,8 +1343,8 @@ class PostProcessorWorker:
except RuntimeError as e_fetch_comment: except RuntimeError as e_fetch_comment:
self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}") self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}")
except Exception as e_generic_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" ❌ 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}")
else: 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.") 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_filename_format="{published} {title}",
manga_custom_date_format="YYYY-MM-DD" , manga_custom_date_format="YYYY-MM-DD" ,
sfp_threshold=None, sfp_threshold=None,
creator_name_cache=None creator_name_cache=None,
proxies=None
): ):
super().__init__() super().__init__()
self.api_url_input = api_url_input self.api_url_input = api_url_input
self.output_dir = output_dir self.output_dir = output_dir
@@ -2404,6 +2409,7 @@ class DownloadThread(QThread):
self.domain_override = domain_override self.domain_override = domain_override
self.sfp_threshold = sfp_threshold self.sfp_threshold = sfp_threshold
self.creator_name_cache = creator_name_cache self.creator_name_cache = creator_name_cache
self.proxies = proxies
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
@@ -2437,6 +2443,7 @@ class DownloadThread(QThread):
self.logger(" Starting post fetch (single-threaded download process)...") self.logger(" Starting post fetch (single-threaded download process)...")
# --- FIX: Removed duplicate proxies argument here ---
post_generator = download_from_api( post_generator = download_from_api(
self.api_url_input, self.api_url_input,
logger=self.logger, logger=self.logger,
@@ -2451,7 +2458,8 @@ class DownloadThread(QThread):
app_base_dir=self.app_base_dir, app_base_dir=self.app_base_dir,
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None, manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
processed_post_ids=self.processed_post_ids_set, 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: for posts_batch_data in post_generator:
@@ -2464,6 +2472,7 @@ class DownloadThread(QThread):
was_process_cancelled = True was_process_cancelled = True
break break
# --- FIX: Ensure 'proxies' is in this dictionary ---
worker_args = { worker_args = {
'post_data': individual_post_data, 'post_data': individual_post_data,
'emitter': worker_signals_obj, 'emitter': worker_signals_obj,
@@ -2532,7 +2541,8 @@ class DownloadThread(QThread):
'archive_only_mode': self.archive_only_mode, 'archive_only_mode': self.archive_only_mode,
'manga_custom_filename_format': self.manga_custom_filename_format, 'manga_custom_filename_format': self.manga_custom_filename_format,
'manga_custom_date_format': self.manga_custom_date_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) post_processing_worker = PostProcessorWorker(**worker_args)

View File

@@ -19,12 +19,14 @@ class AllcomicDownloadThread(QThread):
finished_signal = pyqtSignal(int, int, bool) finished_signal = pyqtSignal(int, int, bool)
overall_progress_signal = pyqtSignal(int, int) 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) super().__init__(parent)
self.comic_url = url self.comic_url = url
self.output_dir = output_dir self.output_dir = output_dir
self.is_cancelled = False self.is_cancelled = False
self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event() self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
self.proxies = proxies # Store the proxies
def _check_pause(self): def _check_pause(self):
if self.is_cancelled: return True if self.is_cancelled: return True
@@ -40,13 +42,19 @@ class AllcomicDownloadThread(QThread):
grand_total_dl = 0 grand_total_dl = 0
grand_total_skip = 0 grand_total_skip = 0
# Create the scraper session ONCE for the entire job if self.proxies:
scraper = cloudscraper.create_scraper( self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True} else:
) self.progress_signal.emit(" 🌍 Network: Direct Connection (No Proxy)")
# Pass the scraper to the function scraper = requests.Session()
chapters_to_download = allcomic_get_list(scraper, self.comic_url, self.progress_signal.emit) 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: if not chapters_to_download:
chapters_to_download = [self.comic_url] chapters_to_download = [self.comic_url]
@@ -57,8 +65,9 @@ class AllcomicDownloadThread(QThread):
if self._check_pause(): break if self._check_pause(): break
self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --") 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: if not image_urls:
self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.") 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) self.overall_progress_signal.emit(total_files_in_chapter, 0)
headers = {'Referer': chapter_url} 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): for i, img_url in enumerate(image_urls):
if self._check_pause(): break if self._check_pause(): break
@@ -97,8 +109,9 @@ class AllcomicDownloadThread(QThread):
if self._check_pause(): break if self._check_pause(): break
try: try:
self.progress_signal.emit(f" Downloading ({i+1}/{total_files_in_chapter}): '{filename}' (Attempt {attempt + 1})...") 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() response.raise_for_status()
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
@@ -125,7 +138,7 @@ class AllcomicDownloadThread(QThread):
grand_total_skip += 1 grand_total_skip += 1
self.overall_progress_signal.emit(total_files_in_chapter, i + 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 if self._check_pause(): break

View File

@@ -2,8 +2,8 @@ import os
import time import time
import requests import requests
import re import re
import random # Needed for random delays
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, wait
from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtCore import QThread, pyqtSignal
from ...core.deviantart_client import DeviantArtClient from ...core.deviantart_client import DeviantArtClient
from ...utils.file_utils import clean_folder_name from ...utils.file_utils import clean_folder_name
@@ -14,28 +14,29 @@ class DeviantArtDownloadThread(QThread):
overall_progress_signal = pyqtSignal(int, int) overall_progress_signal = pyqtSignal(int, int)
finished_signal = pyqtSignal(int, int, bool, list) 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) super().__init__(parent)
self.url = url self.url = url
self.output_dir = output_dir self.output_dir = output_dir
self.pause_event = pause_event self.pause_event = pause_event
self.cancellation_event = cancellation_event self.cancellation_event = cancellation_event
self.proxies = proxies # Store proxies
# --- 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.parent_app = parent self.parent_app = parent
self.download_count = 0 self.download_count = 0
self.skip_count = 0 self.skip_count = 0
# --- THREAD SETTINGS ---
self.max_threads = 10
def run(self): 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("=" * 40)
self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}") self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}")
self.progress_signal.emit(f" Using {self.max_threads} parallel threads.")
try: try:
if not self.client.authenticate(): if not self.client.authenticate():
@@ -91,26 +92,25 @@ class DeviantArtDownloadThread(QThread):
if not os.path.exists(base_folder): if not os.path.exists(base_folder):
os.makedirs(base_folder, exist_ok=True) 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 if self._check_pause_cancel(): break
self._process_deviation_task(deviation, base_folder)
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)
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): def _process_deviation_task(self, deviation, base_folder):
if self._check_pause_cancel(): return if self._check_pause_cancel(): return
@@ -173,7 +173,7 @@ class DeviantArtDownloadThread(QThread):
final_filename = f"{clean_folder_name(new_name)}{ext}" final_filename = f"{clean_folder_name(new_name)}{ext}"
except Exception as e: 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 save_dir = override_dir if override_dir else self.output_dir
if not os.path.exists(save_dir): if not os.path.exists(save_dir):
@@ -189,7 +189,11 @@ class DeviantArtDownloadThread(QThread):
try: try:
self.progress_signal.emit(f" ⬇️ Downloading: {final_filename}") 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() r.raise_for_status()
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:

View File

@@ -1,6 +1,6 @@
import os import os
import time import time
import cloudscraper import requests
from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtCore import QThread, pyqtSignal
from ...utils.file_utils import clean_folder_name 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' } 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): def __init__(self, gallery_data, output_dir, parent=None):
super().__init__(parent) super().__init__(parent)
self.gallery_data = gallery_data self.gallery_data = gallery_data
self.output_dir = output_dir self.output_dir = output_dir
self.is_cancelled = False self.is_cancelled = False
self.proxies = None # Placeholder, will be injected by main_window
def run(self): 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')}") title = self.gallery_data.get("title", {}).get("english", f"gallery_{self.gallery_data.get('id')}")
gallery_id = self.gallery_data.get("id") gallery_id = self.gallery_data.get("id")
media_id = self.gallery_data.get("media_id") media_id = self.gallery_data.get("media_id")
pages_info = self.gallery_data.get("pages", []) pages_info = self.gallery_data.get("pages", [])
folder_name = clean_folder_name(title) 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: try:
os.makedirs(gallery_path, exist_ok=True) os.makedirs(save_path, exist_ok=True)
except OSError as e: self.progress_signal.emit(f" Saving to: {folder_name}")
self.progress_signal.emit(f"❌ Critical error creating directory: {e}") except Exception as e:
self.progress_signal.emit(f" ❌ Error creating directory: {e}")
self.finished_signal.emit(0, len(pages_info), False) self.finished_signal.emit(0, len(pages_info), False)
return return
self.progress_signal.emit(f"⬇️ Downloading '{title}' to folder '{folder_name}'...")
scraper = cloudscraper.create_scraper()
download_count = 0 download_count = 0
skip_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): for i, page_data in enumerate(pages_info):
if self.is_cancelled: if self.is_cancelled: break
break
page_num = i + 1
ext_char = page_data.get('t', 'j') file_ext = self.EXTENSION_MAP.get(page_data.get('t'), 'jpg')
extension = self.EXTENSION_MAP.get(ext_char, 'jpg') local_filename = f"{i+1:03d}.{file_ext}"
filepath = os.path.join(save_path, local_filename)
relative_path = f"/galleries/{media_id}/{page_num}.{extension}"
local_filename = f"{page_num:03d}.{extension}"
filepath = os.path.join(gallery_path, local_filename)
if os.path.exists(filepath): 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 skip_count += 1
continue continue
download_successful = False download_successful = False
# Try servers until one works
for server in self.IMAGE_SERVERS: for server in self.IMAGE_SERVERS:
if self.is_cancelled: if self.is_cancelled: break
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: 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 = { 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}/' '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: if response.status_code == 200:
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
@@ -86,12 +96,14 @@ class NhentaiDownloadThread(QThread):
f.write(chunk) f.write(chunk)
download_count += 1 download_count += 1
download_successful = True download_successful = True
break break # Stop trying servers
else: 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: 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: if not download_successful:
self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.") self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.")

View File

@@ -5,10 +5,11 @@ import sys
# --- PyQt5 Imports --- # --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtGui import QIntValidator # <--- NEW: Added for Port validation
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit, QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
QTabWidget, QWidget, QFileDialog # Added QFileDialog QTabWidget, QWidget, QFileDialog
) )
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
@@ -21,7 +22,9 @@ from ...config.constants import (
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY, RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
DATE_PREFIX_FORMAT_KEY, DATE_PREFIX_FORMAT_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_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 from ...services.updater import UpdateChecker, UpdateDownloader
@@ -118,16 +121,15 @@ class FutureSettingsDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.parent_app = parent_app_ref self.parent_app = parent_app_ref
self.setModal(True) self.setModal(True)
self.update_downloader_thread = None # To keep a reference self.update_downloader_thread = None
app_icon = get_app_icon_object() app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull(): if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon) self.setWindowIcon(app_icon)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
# Use a more balanced aspect ratio
scale_factor = screen_height / 1000.0 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_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
@@ -135,6 +137,9 @@ class FutureSettingsDialog(QDialog):
self._init_ui() self._init_ui()
self._retranslate_ui() self._retranslate_ui()
self._apply_theme() self._apply_theme()
# <--- NEW: Load proxy settings on init
self._load_proxy_settings()
def _init_ui(self): def _init_ui(self):
"""Initializes all UI components and layouts for the dialog.""" """Initializes all UI components and layouts for the dialog."""
@@ -147,14 +152,16 @@ class FutureSettingsDialog(QDialog):
# --- Create Tabs --- # --- Create Tabs ---
self.display_tab = QWidget() self.display_tab = QWidget()
self.downloads_tab = QWidget() self.downloads_tab = QWidget()
self.network_tab = QWidget() # <--- NEW: Network Tab
self.updates_tab = QWidget() self.updates_tab = QWidget()
# Add tabs to the widget # Add tabs to the widget
self.tab_widget.addTab(self.display_tab, "Display") self.tab_widget.addTab(self.display_tab, "Display")
self.tab_widget.addTab(self.downloads_tab, "Downloads") 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") self.tab_widget.addTab(self.updates_tab, "Updates")
# --- Populate Display Tab --- # [Display Tab Code (Unchanged) ...]
display_tab_layout = QVBoxLayout(self.display_tab) display_tab_layout = QVBoxLayout(self.display_tab)
self.display_group_box = QGroupBox() self.display_group_box = QGroupBox()
display_layout = QGridLayout(self.display_group_box) display_layout = QGridLayout(self.display_group_box)
@@ -184,9 +191,9 @@ class FutureSettingsDialog(QDialog):
display_layout.addWidget(self.resolution_combo_box, 3, 1) display_layout.addWidget(self.resolution_combo_box, 3, 1)
display_tab_layout.addWidget(self.display_group_box) 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) downloads_tab_layout = QVBoxLayout(self.downloads_tab)
self.download_settings_group_box = QGroupBox() self.download_settings_group_box = QGroupBox()
download_settings_layout = QGridLayout(self.download_settings_group_box) 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) self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2) download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
# --- START: Add new Load/Save buttons ---
settings_file_layout = QHBoxLayout() settings_file_layout = QHBoxLayout()
self.load_settings_button = QPushButton() self.load_settings_button = QPushButton()
self.save_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.addWidget(self.save_settings_button)
settings_file_layout.addStretch(1) settings_file_layout.addStretch(1)
# Add this new layout to the grid download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2)
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2) # Row 5, span 2 cols
# Connect signals
self.load_settings_button.clicked.connect(self._handle_load_settings) self.load_settings_button.clicked.connect(self._handle_load_settings)
self.save_settings_button.clicked.connect(self._handle_save_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.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) updates_tab_layout = QVBoxLayout(self.updates_tab)
self.update_group_box = QGroupBox() self.update_group_box = QGroupBox()
update_layout = QGridLayout(self.update_group_box) 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) update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
updates_tab_layout.addWidget(self.update_group_box) 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) --- # --- OK Button (outside tabs) ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -266,16 +326,17 @@ class FutureSettingsDialog(QDialog):
# --- Tab Titles --- # --- Tab Titles ---
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display")) 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(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.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
self.theme_label.setText(self._tr("theme_label", "Theme:")) self.theme_label.setText(self._tr("theme_label", "Theme:"))
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:")) self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
self.language_label.setText(self._tr("language_label", "Language:")) self.language_label.setText(self._tr("language_label", "Language:"))
self.window_size_label.setText(self._tr("window_size_label", "Window Size:")) 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.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.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:")) 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.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.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.")) 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.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.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.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.")) 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")) self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
current_version = self.parent_app.windowTitle().split(' v')[-1] 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.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.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")) self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
# --- General ---
self._update_theme_toggle_button_text() self._update_theme_toggle_button_text()
self.ok_button.setText(self._tr("ok_button", "OK")) self.ok_button.setText(self._tr("ok_button", "OK"))
# --- Load Data ---
self._populate_display_combo_boxes() self._populate_display_combo_boxes()
self._populate_language_combo_box() self._populate_language_combo_box()
self._populate_post_download_action_combo() self._populate_post_download_action_combo()
self._load_date_prefix_format() self._load_date_prefix_format()
self._load_checkbox_states() 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): def _check_for_updates(self):
self.check_update_button.setEnabled(False) self.check_update_button.setEnabled(False)
self.update_status_label.setText(self._tr("update_status_checking", "Checking...")) 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>fap-nation.org/</li>
<li>Discord</li> <li>Discord</li>
<li>allporncomic.com</li> <li>allporncomic.com</li>
<li>allporncomic.com</li>
<li>hentai2read.com</li> <li>hentai2read.com</li>
<li>mangadex.org</li> <li>mangadex.org</li>
<li>Simpcity</li> <li>Simpcity</li>
@@ -279,6 +278,46 @@ class HelpGuideDialog(QDialog):
</ul> </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", ("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> <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", ("Utility & Advanced Options",
""" """
<p>These features provide advanced control over your downloads, sessions, and application settings.</p> <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> <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> <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> <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> <li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
</ul> </ul>
</li> </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> <li><b>Updates Tab:</b> Check for and install new application updates.</li>
</ul> </ul>
@@ -605,7 +654,8 @@ class HelpGuideDialog(QDialog):
main_layout.addLayout(content_layout, 1) main_layout.addLayout(content_layout, 1)
self.nav_list = QListWidget() 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 # Styles are now set in the __init__ method
content_layout.addWidget(self.nav_list) 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.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
self._default_checkbox_tooltip = ( self._default_checkbox_tooltip = (
"If checked, the settings from the selected profile will be loaded into the main window.\n" "If checked, the settings fields will be unlocked and editable.\n"
"You can then modify them. When you start the download, the new settings will be saved to the profile." "If unchecked, settings will still load, but in 'Read-Only' mode."
) )
self._init_ui() self._init_ui()
@@ -65,13 +65,17 @@ class UpdateCheckDialog(QDialog):
self.list_widget.itemChanged.connect(self._handle_item_changed) self.list_widget.itemChanged.connect(self._handle_item_changed)
layout.addWidget(self.list_widget) layout.addWidget(self.list_widget)
# --- NEW: Checkbox to Load Settings --- # Renamed text to reflect new behavior
self.load_settings_checkbox = QCheckBox("Load profile settings into UI (Edit Settings)") self.edit_settings_checkbox = QCheckBox("Enable Editing (Unlock Settings)")
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip) self.edit_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
layout.addWidget(self.load_settings_checkbox)
# 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 = QHBoxLayout()
button_layout.setSpacing(6) # small even spacing between all buttons 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.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.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.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): def _load_profiles(self):
"""Loads all .json files from the creator_profiles directory as checkable items.""" """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: with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(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: if 'creator_url' in data and 'processed_post_ids' in data:
creator_name = os.path.splitext(filename)[0] creator_name = os.path.splitext(filename)[0]
profiles_found.append({'name': creator_name, 'data': data}) profiles_found.append({'name': creator_name, 'data': data})
@@ -147,7 +151,6 @@ class UpdateCheckDialog(QDialog):
for profile_info in profiles_found: for profile_info in profiles_found:
item = QListWidgetItem(profile_info['name']) item = QListWidgetItem(profile_info['name'])
item.setData(Qt.UserRole, profile_info) item.setData(Qt.UserRole, profile_info)
# --- Make item checkable ---
item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked) item.setCheckState(Qt.Unchecked)
self.list_widget.addItem(item) self.list_widget.addItem(item)
@@ -158,14 +161,13 @@ class UpdateCheckDialog(QDialog):
self.check_button.setEnabled(False) self.check_button.setEnabled(False)
self.select_all_button.setEnabled(False) self.select_all_button.setEnabled(False)
self.deselect_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): def _toggle_all_checkboxes(self):
"""Handles Select All and Deselect All button clicks.""" """Handles Select All and Deselect All button clicks."""
sender = self.sender() sender = self.sender()
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked 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) self.list_widget.blockSignals(True)
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
item = self.list_widget.item(i) item = self.list_widget.item(i)
@@ -173,13 +175,12 @@ class UpdateCheckDialog(QDialog):
item.setCheckState(check_state) item.setCheckState(check_state)
self.list_widget.blockSignals(False) self.list_widget.blockSignals(False)
# Manually trigger the update once after batch change
self._handle_item_changed(None) self._handle_item_changed(None)
def _handle_item_changed(self, item): def _handle_item_changed(self, item):
""" """
Monitors how many items are checked. 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 checked_count = 0
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
@@ -187,15 +188,15 @@ class UpdateCheckDialog(QDialog):
checked_count += 1 checked_count += 1
if checked_count > 1: if checked_count > 1:
self.load_settings_checkbox.setChecked(False) self.edit_settings_checkbox.setChecked(False)
self.load_settings_checkbox.setEnabled(False) self.edit_settings_checkbox.setEnabled(False)
self.load_settings_checkbox.setToolTip( self.edit_settings_checkbox.setToolTip(
self._tr("update_check_multi_selection_warning", self._tr("update_check_multi_selection_warning",
"Editing settings is disabled when multiple profiles are selected.") "Editing settings is disabled when multiple profiles are selected.")
) )
else: else:
self.load_settings_checkbox.setEnabled(True) self.edit_settings_checkbox.setEnabled(True)
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip) self.edit_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
def on_check_selected(self): def on_check_selected(self):
"""Handles the 'Check Selected' button click.""" """Handles the 'Check Selected' button click."""
@@ -221,6 +222,18 @@ class UpdateCheckDialog(QDialog):
return self.selected_profiles_list return self.selected_profiles_list
def should_load_into_ui(self): 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) Returns True if the settings SHOULD be loaded into the UI.
return self.load_settings_checkbox.isEnabled() and self.load_settings_checkbox.isChecked()
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.download_location_label_widget = None
self.remove_from_filename_label_widget = None self.remove_from_filename_label_widget = None
self.skip_words_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) setup_ui(self)
self._connect_signals() self._connect_signals()
if hasattr(self, 'character_input'): if hasattr(self, 'character_input'):
@@ -366,18 +366,14 @@ class DownloaderApp (QWidget ):
def add_current_settings_to_queue(self): def add_current_settings_to_queue(self):
"""Saves the current UI settings as a JSON job file with creator-specific paths.""" """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): def get_creator_specific_path(base_dir, folder_name):
if not folder_name: if not folder_name:
return base_dir return base_dir
safe_name = clean_folder_name(folder_name) 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): if base_dir.replace('\\', '/').rstrip('/').endswith(safe_name):
return base_dir return base_dir
return os.path.join(base_dir, safe_name) return os.path.join(base_dir, safe_name)
# ------------------------------------------
# --- SCENARIO 1: Items from Creator Selection (Popup) ---
if self.favorite_download_queue: if self.favorite_download_queue:
count = 0 count = 0
base_settings = self._get_current_ui_settings_as_dict() 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.") QMessageBox.warning(self, "Queue Error", "Failed to add selected items to queue.")
return return
# --- SCENARIO 2: Manual URL Entry ---
url = self.link_input.text().strip() url = self.link_input.text().strip()
if not url: if not url:
QMessageBox.warning(self, "Input Error", "Cannot add to queue: URL is empty.") 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 = self._get_current_ui_settings_as_dict()
settings['api_url'] = url settings['api_url'] = url
# Attempt to resolve name from URL + Cache (creators.json)
service, user_id, post_id = extract_post_info(url) service, user_id, post_id = extract_post_info(url)
name_hint = "Job" name_hint = "Job"
if service and user_id: if service and user_id:
# Try to find name in your local creators cache
cache_key = (service.lower(), str(user_id)) cache_key = (service.lower(), str(user_id))
cached_name = self.creator_name_cache.get(cache_key) cached_name = self.creator_name_cache.get(cache_key)
if cached_name: if cached_name:
# CASE A: Creator Found -> Use Creator Name
name_hint = cached_name name_hint = cached_name
settings['output_dir'] = get_creator_specific_path(settings['output_dir'], cached_name) settings['output_dir'] = get_creator_specific_path(settings['output_dir'], cached_name)
else: 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: if post_id:
folder_name = str(post_id) folder_name = str(post_id)
else: else:
@@ -476,7 +469,7 @@ class DownloaderApp (QWidget ):
QMessageBox.information(self, "Queue Empty", "No job files found in appdata/jobs.") QMessageBox.information(self, "Queue Empty", "No job files found in appdata/jobs.")
return return
# --- FIX: Clear error log at the start of the entire queue session ---
self.permanently_failed_files_for_dialog.clear() self.permanently_failed_files_for_dialog.clear()
self._update_error_button_count() 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['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_mode'] = self.keep_duplicates_mode
settings['keep_duplicates_limit'] = self.keep_duplicates_limit 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 return settings
@@ -2950,6 +2968,25 @@ class DownloaderApp (QWidget ):
else: else:
self.log_signal.emit(" Link export was cancelled by the user.") 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 ): def get_filter_mode (self ):
if self.radio_more and self.radio_more.isChecked(): if self.radio_more and self.radio_more.isChecked():
@@ -3218,7 +3255,6 @@ class DownloaderApp (QWidget ):
if self.single_pdf_setting: if self.single_pdf_setting:
self.use_subfolder_per_post_checkbox.setChecked(False) 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()}") self.log_signal.emit(f" 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
if is_any_pdf_mode: if is_any_pdf_mode:
status_single = "Enabled" if self.single_pdf_setting else "Disabled" 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.") self.log_signal.emit(" ↳ Multithreading disabled for PDF export.")
else: else:
# --- User clicked Cancel: Revert to default ---
self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.") self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.")
if hasattr(self, 'radio_all'): if hasattr(self, 'radio_all'):
self.radio_all.setChecked(True) 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: elif button != self.radio_more and checked:
self.radio_more.setText("More") self.radio_more.setText("More")
self.more_filter_scope = None self.more_filter_scope = None
self.single_pdf_setting = False self.single_pdf_setting = False
self.add_info_in_pdf_setting = False # Reset setting 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'): if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True) self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode() # Re-check manga logic self._update_multithreading_for_date_mode() # Re-check manga logic
@@ -4158,9 +4193,12 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
# START of the new refactored block
service, id1, id2 = extract_post_info(api_url) 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( specialized_thread = create_downloader_thread(
main_app=self, main_app=self,
api_url=api_url, api_url=api_url,
@@ -4183,18 +4221,18 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(False) self.set_ui_enabled(False)
self.download_thread = specialized_thread 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._connect_specialized_thread_signals(self.download_thread)
self.download_thread.start() self.download_thread.start()
self._update_button_states_and_connections() self._update_button_states_and_connections()
return True 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 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: 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(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.") 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: if should_use_multithreading_for_posts:
log_messages.append(f" Number of Post Worker Threads: {effective_num_post_workers}") 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: 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.") 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) self.set_ui_enabled(False)
from src.config.constants import FOLDER_NAME_STOP_WORDS from src.config.constants import FOLDER_NAME_STOP_WORDS
current_proxies = self._get_current_ui_settings_as_dict().get('proxies')
args_template = { args_template = {
'api_url_input': api_url, 'api_url_input': api_url,
'download_root': effective_output_dir_for_run, 'download_root': effective_output_dir_for_run,
@@ -4715,7 +4761,8 @@ class DownloaderApp (QWidget ):
'fetch_first': fetch_first_enabled, 'fetch_first': fetch_first_enabled,
'sfp_threshold': download_commands.get('sfp_threshold'), 'sfp_threshold': download_commands.get('sfp_threshold'),
'handle_unknown_mode': handle_unknown_command, '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 args_template['override_output_dir'] = override_output_dir
@@ -4741,7 +4788,8 @@ class DownloaderApp (QWidget ):
'app_base_dir': app_base_dir_for_cookies, 'app_base_dir': app_base_dir_for_cookies,
'manga_filename_style_for_sort_check': self.manga_filename_style, 'manga_filename_style_for_sort_check': self.manga_filename_style,
'processed_post_ids': processed_post_ids_for_this_run, '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) 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.is_ready_to_download_batch_update = True
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.") self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
self.set_ui_enabled(True) # Re-enable UI self.set_ui_enabled(True) # Re-enable UI first
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
# [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): def _start_download_of_batch_update(self):
""" """
@@ -5097,8 +5191,7 @@ class DownloaderApp (QWidget ):
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:] ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
# 1. Define all LIVE RUNTIME arguments. current_proxies = self._get_current_ui_settings_as_dict().get('proxies')
# These are taken from the current app state and are the same for all workers.
live_runtime_args = { live_runtime_args = {
'emitter': self.worker_to_gui_queue, 'emitter': self.worker_to_gui_queue,
'creator_name_cache': self.creator_name_cache, 'creator_name_cache': self.creator_name_cache,
@@ -5128,7 +5221,8 @@ class DownloaderApp (QWidget ):
'use_cookie': self.use_cookie_checkbox.isChecked(), 'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(), 'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath, '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. # 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_manga_filename_style_button_text()
self._update_multipart_toggle_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): def start_multi_threaded_download(self, num_post_workers, **kwargs):
""" """
Initializes and starts the multi-threaded download process. Initializes and starts the multi-threaded download process.
@@ -5406,8 +5513,13 @@ class DownloaderApp (QWidget ):
global PostProcessorWorker, download_from_api global PostProcessorWorker, download_from_api
worker_args_template = fetcher_args['worker_args_template'] 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: try:
# This single call now handles all fetching logic, including 'Fetch First'. # This single call now handles all fetching logic, including 'Fetch First'.
post_generator = download_from_api( post_generator = download_from_api(
@@ -5424,7 +5536,8 @@ class DownloaderApp (QWidget ):
app_base_dir=worker_args_template.get('app_base_dir'), app_base_dir=worker_args_template.get('app_base_dir'),
manga_filename_style_for_sort_check=worker_args_template.get('manga_filename_style'), manga_filename_style_for_sort_check=worker_args_template.get('manga_filename_style'),
processed_post_ids=worker_args_template.get('processed_post_ids', []), 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:] ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]