9 Commits

Author SHA1 Message Date
Yuvi9587
ef9abacc5d Commit 2025-12-28 18:15:30 +05:30
Yuvi9587
6a36179136 Commit 2025-12-28 09:34:49 +05:30
Yuvi9587
fae9a4bbe2 Commit 2025-12-28 09:25:36 +05:30
Yuvi9587
1ad1e53b57 Commit 2025-12-28 09:23:20 +05:30
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
18 changed files with 828 additions and 276 deletions

View File

@@ -75,6 +75,7 @@ PROXY_HOST_KEY = "proxy/host"
PROXY_PORT_KEY = "proxy/port"
PROXY_USERNAME_KEY = "proxy/username"
PROXY_PASSWORD_KEY = "proxy/password"
PROXY_TYPE_KEY = "proxy_type"
# --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>"

View File

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

View File

@@ -4,6 +4,10 @@ from urllib.parse import urlparse
import json
import requests
import cloudscraper
import ssl
from requests.adapters import HTTPAdapter
from urllib3.poolmanager import PoolManager
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import (
STYLE_DATE_POST_TITLE,
@@ -11,6 +15,23 @@ from ..config.constants import (
STYLE_POST_TITLE_GLOBAL_NUMBERING
)
class CustomSSLAdapter(HTTPAdapter):
"""
A custom HTTPAdapter that forces check_hostname=False when using SSL.
This prevents the 'Cannot set verify_mode to CERT_NONE' error.
"""
def init_poolmanager(self, connections, maxsize, block=False):
ctx = ssl.create_default_context()
# Crucial: Disable hostname checking FIRST, then set verify mode
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self.poolmanager = PoolManager(
num_pools=connections,
maxsize=maxsize,
block=block,
ssl_context=ctx
)
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None, proxies=None):
"""
@@ -40,8 +61,11 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
log_message += f" (Attempt {attempt + 1}/{max_retries})"
logger(log_message)
request_timeout = (30, 120) if proxies else (15, 60)
try:
with requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict, proxies=proxies) as response:
with requests.get(paginated_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False) as response:
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
@@ -84,32 +108,62 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
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.
Includes RETRY logic for 429 Rate Limit errors.
"""
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f" Fetching full content for post ID {post_id}...")
# FIX: Ensure scraper session is closed after use
scraper = None
try:
scraper = cloudscraper.create_scraper()
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, proxies=proxies)
response.raise_for_status()
# Retry settings
max_retries = 4
for attempt in range(max_retries + 1):
scraper = None
try:
scraper = cloudscraper.create_scraper()
# Mount custom SSL adapter
adapter = CustomSSLAdapter()
scraper.mount("https://", adapter)
full_post_data = response.json()
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)
if response.status_code == 429:
wait_time = 20 + (attempt * 10) # 20s, 30s, 40s...
logger(f" ⚠️ Rate Limited (429) on post {post_id}. Waiting {wait_time} seconds before retrying...")
time.sleep(wait_time)
continue # Try loop again
if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0]
if isinstance(full_post_data, dict) and 'post' in full_post_data:
return full_post_data['post']
return full_post_data
response.raise_for_status()
except Exception as e:
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
return None
finally:
if scraper:
scraper.close()
full_post_data = response.json()
if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0]
if isinstance(full_post_data, dict) and 'post' in full_post_data:
return full_post_data['post']
return full_post_data
except Exception as e:
# Catch "Too Many Requests" if it wasn't caught by status_code check above
if "429" in str(e) or "Too Many Requests" in str(e):
if attempt < max_retries:
wait_time = 20 + (attempt * 10)
logger(f" ⚠️ Rate Limit Error caught: {e}. Waiting {wait_time}s...")
time.sleep(wait_time)
continue
# Only log error if this was the last attempt
if attempt == max_retries:
logger(f" ❌ Failed to fetch full content for post {post_id} after {max_retries} retries: {e}")
return None
finally:
if scraper:
scraper.close()
return 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."""
@@ -120,7 +174,9 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
logger(f" Fetching comments: {comments_api_url}")
try:
with requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict, proxies=proxies) as response:
request_timeout = (30, 60) if proxies else (10, 30)
with requests.get(comments_api_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False) as response:
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
@@ -180,7 +236,9 @@ def download_from_api(
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try:
with requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api, proxies=proxies) as direct_response:
request_timeout = (30, 60) if proxies else (10, 30)
with requests.get(direct_post_api_url, headers=headers, timeout=request_timeout, cookies=cookies_for_api, proxies=proxies, verify=False) as direct_response:
direct_response.raise_for_status()
direct_response.encoding = 'utf-8'
direct_post_data = direct_response.json()
@@ -207,11 +265,9 @@ def download_from_api(
if target_post_id and (start_page or end_page):
logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
# --- FIXED LOGIC HERE ---
# Define which styles require fetching ALL posts first (Sequential Mode)
styles_requiring_fetch_all = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
# Only enable "fetch all and sort" if the current style is explicitly in the list above
is_manga_mode_fetch_all_and_sort_oldest_first = (
manga_mode and
(manga_filename_style_for_sort_check in styles_requiring_fetch_all) and

View File

@@ -11,9 +11,18 @@ class DeviantArtClient:
CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1"
BASE_API = "https://www.deviantart.com/api/v1/oauth2"
def __init__(self, logger_func=print):
# 1. Accept proxies in init
def __init__(self, logger_func=print, proxies=None):
self.session = requests.Session()
# Headers matching 1.py (Firefox)
# 2. Configure Session with Proxy & SSL settings immediately
if proxies:
self.session.proxies.update(proxies)
self.session.verify = False # Ignore SSL for proxies
self.proxies_enabled = True
else:
self.proxies_enabled = False
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
@@ -41,7 +50,10 @@ class DeviantArtClient:
"client_id": self.CLIENT_ID,
"client_secret": self.CLIENT_SECRET
}
resp = self.session.post(url, data=data, timeout=10)
# 3. Smart timeout (longer if proxy)
req_timeout = 30 if self.proxies_enabled else 10
resp = self.session.post(url, data=data, timeout=req_timeout)
resp.raise_for_status()
data = resp.json()
self.access_token = data.get("access_token")
@@ -63,18 +75,22 @@ class DeviantArtClient:
retries = 0
max_retries = 4
backoff_delay = 2
# 4. Smart timeout
req_timeout = 30 if self.proxies_enabled else 20
while True:
try:
resp = self.session.get(url, params=params, timeout=20)
resp = self.session.get(url, params=params, timeout=req_timeout)
# 429: Rate Limit (Retry infinitely like 1.py)
# 429: Rate Limit
if resp.status_code == 429:
retry_after = resp.headers.get('Retry-After')
if retry_after:
sleep_time = int(retry_after) + 1
sleep_time = int(retry_after) + 2 # Add buffer
else:
sleep_time = 5 # Default sleep from 1.py
# 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)
@@ -90,7 +106,7 @@ class DeviantArtClient:
raise Exception("Failed to refresh token")
if 400 <= resp.status_code < 500:
resp.raise_for_status() # This raises immediately, breaking the loop
resp.raise_for_status()
if 500 <= resp.status_code < 600:
resp.raise_for_status()
@@ -105,12 +121,9 @@ class DeviantArtClient:
except requests.exceptions.HTTPError as e:
if e.response is not None and 400 <= e.response.status_code < 500:
raise e
# Otherwise fall through to general retry logic (for 5xx)
pass
except requests.exceptions.RequestException as e:
# Network errors / 5xx errors -> Retry
if retries < max_retries:
self._log_once("conn_error", f" [DeviantArt] Connection error: {e}. Retrying...")
time.sleep(backoff_delay)
@@ -131,7 +144,8 @@ class DeviantArtClient:
def get_deviation_uuid(self, url):
"""Scrapes the deviation page to find the UUID."""
try:
resp = self.session.get(url, timeout=15)
req_timeout = 30 if self.proxies_enabled else 15
resp = self.session.get(url, timeout=req_timeout)
match = re.search(r'"deviationUuid":"([^"]+)"', resp.text)
if match:
return match.group(1)
@@ -144,17 +158,13 @@ class DeviantArtClient:
def get_deviation_content(self, uuid):
"""Fetches download info."""
# 1. Try high-res download endpoint
try:
data = self._api_call(f"/deviation/download/{uuid}")
if 'src' in data:
return data
except:
# If 400/403 (Not downloadable), we fail silently here
# and proceed to step 2 (Metadata fallback)
pass
# 2. Fallback to standard content
try:
meta = self._api_call(f"/deviation/{uuid}")
if 'content' in meta:

0
src/core/hentaifox.txt Normal file
View File

View File

@@ -0,0 +1,59 @@
import requests
import re
from bs4 import BeautifulSoup
BASE_URL = "https://hentaifox.com"
HEADERS = {
"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",
"Referer": "https://hentaifox.com/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
}
def get_gallery_id(url_or_id):
"""Extracts numbers from URL or returns the ID string."""
match = re.search(r"(\d+)", str(url_or_id))
return match.group(1) if match else None
def get_gallery_metadata(gallery_id):
"""
Fetches the main gallery page to get the Title and Total Pages.
Equivalent to the first part of the 'hentaifox' function in .sh file.
"""
url = f"{BASE_URL}/gallery/{gallery_id}/"
response = requests.get(url, headers=HEADERS)
response.raise_for_status()
html = response.text
title_match = re.search(r'<title>(.*?)</title>', html)
title = title_match.group(1).replace(" - HentaiFox", "").strip() if title_match else f"Gallery {gallery_id}"
pages_match = re.search(r'Pages: (\d+)', html)
if not pages_match:
raise ValueError("Could not find total pages count.")
total_pages = int(pages_match.group(1))
return {
"id": gallery_id,
"title": title,
"total_pages": total_pages
}
def get_image_link_for_page(gallery_id, page_num):
"""
Fetches the specific reader page to find the actual image URL.
Equivalent to the loop in the 'hentaifox' function:
url="https://hentaifox.com/g/${id}/${i}/"
"""
url = f"{BASE_URL}/g/{gallery_id}/{page_num}/"
response = requests.get(url, headers=HEADERS)
# Extract image source (Bash: grep -Eo 'data-src="..."')
# Regex looks for: data-src="https://..."
match = re.search(r'data-src="(https://[^"]+)"', response.text)
if match:
return match.group(1)
return None

View File

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

View File

@@ -62,7 +62,8 @@ def robust_clean_name(name):
"""A more robust function to remove illegal characters for filenames and folders."""
if not name:
return ""
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\']'
# FIX: Removed \' from the list so apostrophes are kept
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*]'
cleaned_name = re.sub(illegal_chars_pattern, '', name)
cleaned_name = cleaned_name.strip(' .')
@@ -263,7 +264,7 @@ class PostProcessorWorker:
new_url = parsed_url._replace(netloc=new_domain).geturl()
try:
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True, proxies=self.proxies) as resp:
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True, proxies=self.proxies, verify=False) as resp:
if resp.status_code == 200:
return new_url
except requests.RequestException:
@@ -338,7 +339,7 @@ class PostProcessorWorker:
api_original_filename_for_size_check = file_info.get('_original_name_for_log', file_info.get('name'))
try:
# Use a stream=True HEAD request to get headers without downloading the body
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True, proxies=self.proxies) as head_response:
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True, proxies=self.proxies, verify=False) as head_response:
head_response.raise_for_status()
content_length = head_response.headers.get('Content-Length')
@@ -673,7 +674,7 @@ class PostProcessorWorker:
current_url_to_try = file_url
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies)
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
if response.status_code == 403 and ('kemono.' in current_url_to_try or 'coomer.' in current_url_to_try):
self.logger(f" ⚠️ Got 403 Forbidden for '{api_original_filename}'. Attempting subdomain rotation...")
@@ -682,10 +683,9 @@ class PostProcessorWorker:
self.logger(f" Retrying with new URL: {new_url}")
file_url = new_url
response.close() # Close the old response
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies)
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
response.raise_for_status()
# --- REVISED AND MOVED SIZE CHECK LOGIC ---
total_size_bytes = int(response.headers.get('Content-Length', 0))
if self.skip_file_size_mb is not None:
@@ -694,8 +694,7 @@ class PostProcessorWorker:
if file_size_mb < self.skip_file_size_mb:
self.logger(f" -> Skip File (Size): '{api_original_filename}' is {file_size_mb:.2f} MB, which is smaller than the {self.skip_file_size_mb} MB limit.")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
# If Content-Length is missing, we can't check, so we no longer log a warning here and just proceed.
# --- END OF REVISED LOGIC ---
num_parts_for_file = min(self.multipart_parts_count, MAX_PARTS_FOR_MULTIPART_DOWNLOAD)
@@ -1599,12 +1598,11 @@ class PostProcessorWorker:
should_create_post_subfolder = self.use_post_subfolders
if (not self.use_post_subfolders and self.use_subfolders and
if (not self.use_post_subfolders and
self.sfp_threshold is not None and num_potential_files_in_post >= self.sfp_threshold):
self.logger(f" Post has {num_potential_files_in_post} files (≥{self.sfp_threshold}). Activating Subfolder per Post via [sfp] command.")
should_create_post_subfolder = True
base_folder_names_for_post_content = []
determined_post_save_path_for_history = self.override_output_dir if self.override_output_dir else self.download_root
if not self.extract_links_only and self.use_subfolders:
@@ -2462,6 +2460,7 @@ class DownloadThread(QThread):
proxies=self.proxies
)
processed_count_for_delay = 0
for posts_batch_data in post_generator:
if self.isInterruptionRequested():
was_process_cancelled = True
@@ -2472,7 +2471,11 @@ class DownloadThread(QThread):
was_process_cancelled = True
break
# --- FIX: Ensure 'proxies' is in this dictionary ---
processed_count_for_delay += 1
if processed_count_for_delay > 0 and processed_count_for_delay % 50 == 0:
self.logger(" ⏳ Safety Pause: Waiting 10 seconds to respect server rate limits...")
time.sleep(10)
worker_args = {
'post_data': individual_post_data,
'emitter': worker_signals_obj,

View File

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

View File

@@ -2,8 +2,8 @@ import os
import time
import requests
import re
import random # Needed for random delays
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, wait
from PyQt5.QtCore import QThread, pyqtSignal
from ...core.deviantart_client import DeviantArtClient
from ...utils.file_utils import clean_folder_name
@@ -14,28 +14,29 @@ class DeviantArtDownloadThread(QThread):
overall_progress_signal = pyqtSignal(int, int)
finished_signal = pyqtSignal(int, int, bool, list)
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None):
# 1. Accept proxies in init
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None, proxies=None):
super().__init__(parent)
self.url = url
self.output_dir = output_dir
self.pause_event = pause_event
self.cancellation_event = cancellation_event
# Pass logger to client so we see "Rate Limit" messages in the UI
self.client = DeviantArtClient(logger_func=self.progress_signal.emit)
self.proxies = proxies # Store proxies
self.parent_app = parent
self.download_count = 0
self.skip_count = 0
# --- THREAD SETTINGS ---
# STRICTLY 1 THREAD (Sequential) to match 1.py and avoid Rate Limits
self.max_threads = 1
def run(self):
self.client = DeviantArtClient(logger_func=self.progress_signal.emit, proxies=self.proxies)
if self.proxies:
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
else:
self.progress_signal.emit(" 🌍 Network: Direct Connection")
self.progress_signal.emit("=" * 40)
self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}")
self.progress_signal.emit(f" Mode: Sequential (1 thread) to prevent 429 errors.")
try:
if not self.client.authenticate():
@@ -91,28 +92,25 @@ class DeviantArtDownloadThread(QThread):
if not os.path.exists(base_folder):
os.makedirs(base_folder, exist_ok=True)
with ThreadPoolExecutor(max_workers=self.max_threads) as executor:
while has_more:
while has_more:
if self._check_pause_cancel(): break
data = self.client.get_gallery_folder(username, offset=offset)
results = data.get('results', [])
has_more = data.get('has_more', False)
offset = data.get('next_offset')
if not results: break
for deviation in results:
if self._check_pause_cancel(): break
data = self.client.get_gallery_folder(username, offset=offset)
results = data.get('results', [])
has_more = data.get('has_more', False)
offset = data.get('next_offset')
if not results: break
futures = []
for deviation in results:
if self._check_pause_cancel(): break
future = executor.submit(self._process_deviation_task, deviation, base_folder)
futures.append(future)
# Wait for this batch to finish before getting the next page
wait(futures)
self._process_deviation_task(deviation, base_folder)
# Match 1.py: Sleep 1s between pages to be nice to API
time.sleep(1)
# 4. FIX 429: Add a small random delay between items
# This prevents hammering the API 24 times in a single second.
time.sleep(random.uniform(0.5, 1.2))
time.sleep(1)
def _process_deviation_task(self, deviation, base_folder):
if self._check_pause_cancel(): return
@@ -121,7 +119,6 @@ class DeviantArtDownloadThread(QThread):
title = deviation.get('title', 'Unknown')
try:
# This handles the fallback logic internally
content = self.client.get_deviation_content(dev_id)
if content:
self._download_file(content['src'], deviation, override_dir=base_folder)
@@ -155,7 +152,6 @@ class DeviantArtDownloadThread(QThread):
final_filename = f"{safe_title}{ext}"
# Naming logic
if self.parent_app and self.parent_app.manga_mode_checkbox.isChecked():
try:
creator_name = metadata.get('author', {}).get('username', 'Unknown')
@@ -177,7 +173,7 @@ class DeviantArtDownloadThread(QThread):
final_filename = f"{clean_folder_name(new_name)}{ext}"
except Exception as e:
self.progress_signal.emit(f" ⚠️ Renaming failed ({e}), using default.")
pass
save_dir = override_dir if override_dir else self.output_dir
if not os.path.exists(save_dir):
@@ -193,7 +189,11 @@ class DeviantArtDownloadThread(QThread):
try:
self.progress_signal.emit(f" ⬇️ Downloading: {final_filename}")
with requests.get(file_url, stream=True, timeout=30) as r:
# 5. Determine smart timeout for files
timeout_val = (30, 120) if self.proxies else 30
# 6. Use proxies and verify=False
with requests.get(file_url, stream=True, timeout=timeout_val, proxies=self.proxies, verify=False) as r:
r.raise_for_status()
with open(filepath, 'wb') as f:

View File

@@ -25,6 +25,7 @@ from .saint2_downloader_thread import Saint2DownloadThread
from .simp_city_downloader_thread import SimpCityDownloadThread
from .toonily_downloader_thread import ToonilyDownloadThread
from .deviantart_downloader_thread import DeviantArtDownloadThread
from .hentaifox_downloader_thread import HentaiFoxDownloadThread
def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
"""
@@ -185,6 +186,17 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
cancellation_event=main_app.cancellation_event,
parent=main_app
)
# Handler for HentaiFox (New)
if 'hentaifox.com' in api_url or service == 'hentaifox':
main_app.log_signal.emit("🦊 HentaiFox URL detected.")
return HentaiFoxDownloadThread(
url_or_id=api_url,
output_dir=effective_output_dir_for_run,
parent=main_app
)
# ----------------------
# --- Fallback ---
# If no specific handler matched based on service name or URL pattern, return None.

View File

@@ -0,0 +1,136 @@
import os
import time
import requests
from PyQt5.QtCore import QThread, pyqtSignal
from ...core.hentaifox_client import get_gallery_metadata, get_image_link_for_page, get_gallery_id
from ...utils.file_utils import clean_folder_name
class HentaiFoxDownloadThread(QThread):
progress_signal = pyqtSignal(str) # Log messages
file_progress_signal = pyqtSignal(str, object) # filename, (current_bytes, total_bytes)
# finished_signal: (downloaded_count, skipped_count, was_cancelled, kept_files_list)
finished_signal = pyqtSignal(int, int, bool, list)
def __init__(self, url_or_id, output_dir, parent=None):
super().__init__(parent)
self.gallery_id = get_gallery_id(url_or_id)
self.output_dir = output_dir
self.is_running = True
self.downloaded_count = 0
self.skipped_count = 0
def run(self):
try:
self.progress_signal.emit(f"🔍 [HentaiFox] Fetching metadata for ID: {self.gallery_id}...")
# 1. Get Info
try:
data = get_gallery_metadata(self.gallery_id)
except Exception as e:
self.progress_signal.emit(f"❌ [HentaiFox] Failed to fetch metadata: {e}")
self.finished_signal.emit(0, 0, False, [])
return
title = clean_folder_name(data['title'])
total_pages = data['total_pages']
# 2. Setup Folder
save_folder = os.path.join(self.output_dir, f"[{self.gallery_id}] {title}")
os.makedirs(save_folder, exist_ok=True)
self.progress_signal.emit(f"📂 Saving to: {save_folder}")
self.progress_signal.emit(f"📄 Found {total_pages} pages. Starting download...")
# 3. Iterate and Download
for i in range(1, total_pages + 1):
if not self.is_running:
self.progress_signal.emit("🛑 Download cancelled by user.")
break
# Fetch image link for this specific page
try:
img_url = get_image_link_for_page(self.gallery_id, i)
if img_url:
ext = img_url.split('.')[-1]
filename = f"{i:03d}.{ext}"
filepath = os.path.join(save_folder, filename)
# Check if exists
if os.path.exists(filepath):
self.progress_signal.emit(f"⚠️ [{i}/{total_pages}] Skipped (Exists): {filename}")
self.skipped_count += 1
else:
self.progress_signal.emit(f"⬇️ [{i}/{total_pages}] Downloading: {filename}")
# CALL NEW DOWNLOAD FUNCTION
success = self.download_image_with_progress(img_url, filepath, filename)
if success:
self.progress_signal.emit(f"✅ [{i}/{total_pages}] Finished: {filename}")
self.downloaded_count += 1
else:
self.progress_signal.emit(f"❌ [{i}/{total_pages}] Failed: {filename}")
self.skipped_count += 1
else:
self.progress_signal.emit(f"❌ [{i}/{total_pages}] Error: No image link found.")
self.skipped_count += 1
except Exception as e:
self.progress_signal.emit(f"❌ [{i}/{total_pages}] Exception: {e}")
self.skipped_count += 1
time.sleep(0.5)
# 4. Final Summary
summary = (
f"\n🏁 [HentaiFox] Task Complete!\n"
f" - Total: {total_pages}\n"
f" - Downloaded: {self.downloaded_count}\n"
f" - Skipped: {self.skipped_count}\n"
)
self.progress_signal.emit(summary)
except Exception as e:
self.progress_signal.emit(f"❌ Critical Error: {str(e)}")
self.finished_signal.emit(self.downloaded_count, self.skipped_count, not self.is_running, [])
def download_image_with_progress(self, url, path, filename):
"""Downloads file while emitting byte-level progress signals."""
headers = {
"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",
"Referer": "https://hentaifox.com/"
}
try:
# stream=True is required to get size before downloading body
r = requests.get(url, headers=headers, stream=True, timeout=20)
if r.status_code != 200:
return False
# Get Total Size (in bytes)
total_size = int(r.headers.get('content-length', 0))
downloaded_size = 0
chunk_size = 1024 # 1KB chunks
with open(path, 'wb') as f:
for chunk in r.iter_content(chunk_size):
if not self.is_running:
r.close()
return False
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
self.file_progress_signal.emit(filename, (downloaded_size, total_size))
return True
except Exception as e:
print(f"Download Error: {e}")
return False
def stop(self):
self.is_running = False

View File

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

View File

@@ -249,13 +249,22 @@ class FutureSettingsDialog(QDialog):
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, 1, 0)
proxy_layout.addWidget(self.proxy_host_input, 1, 1)
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()
@@ -263,16 +272,16 @@ class FutureSettingsDialog(QDialog):
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, 2, 0)
proxy_layout.addWidget(self.proxy_port_input, 2, 1)
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, 3, 0)
proxy_layout.addWidget(self.proxy_user_input, 3, 1)
proxy_layout.addWidget(self.proxy_user_label, 4, 0)
proxy_layout.addWidget(self.proxy_user_input, 4, 1)
# Password
self.proxy_pass_label = QLabel()
@@ -280,8 +289,8 @@ class FutureSettingsDialog(QDialog):
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, 4, 0)
proxy_layout.addWidget(self.proxy_pass_input, 4, 1)
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)
@@ -379,19 +388,32 @@ class FutureSettingsDialog(QDialog):
# --- 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)
@@ -399,7 +421,9 @@ class FutureSettingsDialog(QDialog):
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)
@@ -408,16 +432,19 @@ class FutureSettingsDialog(QDialog):
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)
@@ -427,6 +454,7 @@ class FutureSettingsDialog(QDialog):
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)

View File

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

View File

@@ -1,5 +1,7 @@
import os
import re
import sys
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
@@ -18,7 +20,9 @@ try:
self.set_font(self.font_family_main, '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
except ImportError:
except Exception as e:
print(f"\n❌ DEBUG INFO: Import failed. The specific error is: {e}")
print(f"❌ DEBUG INFO: Python running this script is located at: {sys.executable}\n")
FPDF_AVAILABLE = False
FPDF = None
PDF = None
@@ -71,11 +75,6 @@ def add_metadata_page(pdf, post, font_family):
if link_url:
# Styling for clickable link: Blue + Underline
pdf.set_text_color(0, 0, 255)
# Check if font supports underline style directly or just use 'U'
# FPDF standard allows 'U' in style string.
# We use 'U' combined with the font family.
# Note: DejaVu implementation in fpdf2 might handle 'U' automatically or ignore it depending on version,
# but setting text color indicates link clearly enough usually.
pdf.set_font(font_family, 'U', 11)
# Pass the URL to the 'link' parameter
@@ -127,9 +126,9 @@ def create_individual_pdf(post_data, output_filename, font_path, add_info_page=F
font_family = _setup_pdf_fonts(pdf, font_path, logger)
if add_info_page:
# add_metadata_page adds the page start itself
add_metadata_page(pdf, post_data, font_family)
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
else:
pdf.add_page()
@@ -206,7 +205,6 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, add_i
for i, post in enumerate(posts_data):
if add_info_page:
add_metadata_page(pdf, post, font_family)
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
else:
pdf.add_page()
@@ -244,6 +242,9 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, add_i
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
try:
output_dir = os.path.dirname(output_filename)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
pdf.output(output_filename)
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
return True

View File

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

View File

@@ -106,6 +106,7 @@ from .classes.external_link_downloader_thread import ExternalLinkDownloadThread
from .classes.nhentai_downloader_thread import NhentaiDownloadThread
from .classes.downloader_factory import create_downloader_thread
from .classes.kemono_discord_downloader_thread import KemonoDiscordDownloadThread
from .classes.hentaifox_downloader_thread import HentaiFoxDownloadThread
_ff_ver = (datetime.date.today().toordinal() - 735506) // 28
USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; "
@@ -309,6 +310,9 @@ class DownloaderApp (QWidget ):
self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = []
self.single_pdf_mode = False
self.temp_pdf_content_list = []
self.last_effective_download_dir = None
self.save_creator_json_enabled_this_session = True
self.date_prefix_format = self.settings.value(DATE_PREFIX_FORMAT_KEY, "YYYY-MM-DD {post}", type=str)
self.is_single_post_session = False
@@ -346,7 +350,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v7.8.0")
self.setWindowTitle("Kemono Downloader v7.9.1")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@@ -366,18 +370,14 @@ class DownloaderApp (QWidget ):
def add_current_settings_to_queue(self):
"""Saves the current UI settings as a JSON job file with creator-specific paths."""
# --- Helper: Append Name to Path safely ---
def get_creator_specific_path(base_dir, folder_name):
if not folder_name:
return base_dir
safe_name = clean_folder_name(folder_name)
# Avoid double pathing (e.g. if base is .../Artist and we append /Artist again)
if base_dir.replace('\\', '/').rstrip('/').endswith(safe_name):
return base_dir
return os.path.join(base_dir, safe_name)
# ------------------------------------------
# --- SCENARIO 1: Items from Creator Selection (Popup) ---
if self.favorite_download_queue:
count = 0
base_settings = self._get_current_ui_settings_as_dict()
@@ -407,7 +407,7 @@ class DownloaderApp (QWidget ):
QMessageBox.warning(self, "Queue Error", "Failed to add selected items to queue.")
return
# --- SCENARIO 2: Manual URL Entry ---
url = self.link_input.text().strip()
if not url:
QMessageBox.warning(self, "Input Error", "Cannot add to queue: URL is empty.")
@@ -416,23 +416,20 @@ class DownloaderApp (QWidget ):
settings = self._get_current_ui_settings_as_dict()
settings['api_url'] = url
# Attempt to resolve name from URL + Cache (creators.json)
service, user_id, post_id = extract_post_info(url)
name_hint = "Job"
if service and user_id:
# Try to find name in your local creators cache
cache_key = (service.lower(), str(user_id))
cached_name = self.creator_name_cache.get(cache_key)
if cached_name:
# CASE A: Creator Found -> Use Creator Name
name_hint = cached_name
settings['output_dir'] = get_creator_specific_path(settings['output_dir'], cached_name)
else:
# CASE B: Creator NOT Found -> Use Post ID or User ID
# If it's a single post link, 'post_id' will have a value.
# If it's a profile link, 'post_id' is None, so we use 'user_id'.
if post_id:
folder_name = str(post_id)
else:
@@ -476,7 +473,7 @@ class DownloaderApp (QWidget ):
QMessageBox.information(self, "Queue Empty", "No job files found in appdata/jobs.")
return
# --- FIX: Clear error log at the start of the entire queue session ---
self.permanently_failed_files_for_dialog.clear()
self._update_error_button_count()
# -------------------------------------------------------------------
@@ -849,12 +846,24 @@ class DownloaderApp (QWidget ):
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']:
proxy_str = f"http://{settings['proxy_host']}:{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"http://{settings['proxy_username']}:{settings['proxy_password']}@{settings['proxy_host']}:{settings['proxy_port']}"
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
@@ -2963,6 +2972,25 @@ class DownloaderApp (QWidget ):
else:
self.log_signal.emit(" Link export was cancelled by the user.")
def _set_inputs_read_only(self, read_only):
"""Disables input fields (Read-Only mode) but keeps action buttons enabled."""
# List of widgets to disable in Read-Only mode
widgets_to_lock = [
self.link_input, self.dir_input, self.character_input,
self.skip_words_input, self.remove_from_filename_input,
self.custom_folder_input, self.cookie_text_input,
self.thread_count_input, self.start_page_input, self.end_page_input,
self.use_subfolders_checkbox, self.use_subfolder_per_post_checkbox,
self.skip_zip_checkbox, self.download_thumbnails_checkbox,
self.compress_images_checkbox, self.scan_content_images_checkbox,
self.use_cookie_checkbox, self.manga_mode_checkbox,
self.radio_all, self.radio_images, self.radio_videos,
self.char_filter_scope_toggle_button, self.skip_scope_toggle_button
]
for widget in widgets_to_lock:
if widget:
widget.setEnabled(not read_only)
def get_filter_mode (self ):
if self.radio_more and self.radio_more.isChecked():
@@ -3231,7 +3259,6 @@ class DownloaderApp (QWidget ):
if self.single_pdf_setting:
self.use_subfolder_per_post_checkbox.setChecked(False)
# --- Logging ---
self.log_signal.emit(f" 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
if is_any_pdf_mode:
status_single = "Enabled" if self.single_pdf_setting else "Disabled"
@@ -3240,19 +3267,18 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" ↳ Multithreading disabled for PDF export.")
else:
# --- User clicked Cancel: Revert to default ---
self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.")
if hasattr(self, 'radio_all'):
self.radio_all.setChecked(True)
# Case 2: Switched AWAY from the "More" button (e.g., clicked 'Images' or 'All')
elif button != self.radio_more and checked:
self.radio_more.setText("More")
self.more_filter_scope = None
self.single_pdf_setting = False
self.add_info_in_pdf_setting = False # Reset setting
# Restore enabled states for options that PDF mode might have disabled
if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode() # Re-check manga logic
@@ -3896,7 +3922,11 @@ class DownloaderApp (QWidget ):
'txt_file': 'coomer.txt',
'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?'
},
'hentaifox.com': {
'name': 'HentaiFox',
'txt_file': 'hentaifox.txt',
'url_regex': r'https?://(?:www\.)?hentaifox\.com/(?:g|gallery)/\d+/?'
},
'allporncomic.com': {
'name': 'AllPornComic',
'txt_file': 'allporncomic.txt',
@@ -3977,7 +4007,8 @@ class DownloaderApp (QWidget ):
'toonily.com', 'toonily.me',
'hentai2read.com',
'saint2.su', 'saint2.pk',
'imgur.com', 'bunkr.'
'imgur.com', 'bunkr.',
'hentaifox.com'
]
for url in urls_to_download:
@@ -4065,6 +4096,7 @@ class DownloaderApp (QWidget ):
self._clear_stale_temp_files()
self.session_temp_files = []
self.temp_pdf_content_list = []
processed_post_ids_for_restore = []
manga_counters_for_restore = None
@@ -4148,6 +4180,7 @@ class DownloaderApp (QWidget ):
return False
effective_output_dir_for_run = os.path.normpath(main_ui_download_dir) if main_ui_download_dir else ""
self.last_effective_download_dir = effective_output_dir_for_run
if not is_restore:
self._create_initial_session_file(api_url, effective_output_dir_for_run, remaining_queue=self.favorite_download_queue)
@@ -4171,9 +4204,12 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False
# START of the new refactored block
service, id1, id2 = extract_post_info(api_url)
# [NEW] Get proxy settings immediately
ui_settings = self._get_current_ui_settings_as_dict()
proxies_to_use = ui_settings.get('proxies')
specialized_thread = create_downloader_thread(
main_app=self,
api_url=api_url,
@@ -4196,18 +4232,18 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(False)
self.download_thread = specialized_thread
# [NEW] Inject proxies into the thread manually
if hasattr(self.download_thread, 'proxies'):
self.download_thread.proxies = proxies_to_use
self._connect_specialized_thread_signals(self.download_thread)
self.download_thread.start()
self._update_button_states_and_connections()
return True
# END of the new refactored block
if not service or not id1:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
user_id, post_id_from_url = id1, id2
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue:
self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}")
self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.")
@@ -5089,8 +5125,54 @@ class DownloaderApp (QWidget ):
self.is_ready_to_download_batch_update = True
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
self.set_ui_enabled(True) # Re-enable UI
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
self.set_ui_enabled(True) # Re-enable UI first
# [NEW] Apply Read-Only mode if it was selected in the dialog
if getattr(self, 'update_settings_read_only_mode', False):
self._set_inputs_read_only(True)
self._update_button_states_and_connections()
def _show_update_check_dialog(self):
"""Shows the Update Check Dialog and applies Load/Edit logic."""
if self.is_restore_pending:
QMessageBox.warning(self, "Restore Pending", "Please restore or discard the previous session first.")
return
dialog = UpdateCheckDialog(self.user_data_path, self, self)
if dialog.exec_() == QDialog.Accepted:
profiles = dialog.get_selected_profiles()
if not profiles: return
self.active_update_profiles_list = profiles
# --- LOGIC START ---
# 1. ALWAYS Load Settings if appropriate (e.g. Single Profile selected)
# The dialog now returns True for should_load_into_ui() if count == 1, regardless of checkbox
if dialog.should_load_into_ui():
# Load settings from the FIRST selected profile
first_profile_settings = profiles[0]['data'].get('settings', {})
self._load_ui_from_settings_dict(first_profile_settings)
# 2. Check if Editing is Allowed
if dialog.should_enable_editing():
self.update_settings_read_only_mode = False
self.override_update_profile_settings = True # Use UI values for download
self.log_signal.emit(" Settings loaded in EDITABLE mode.")
else:
self.update_settings_read_only_mode = True
self.override_update_profile_settings = False # Use original JSON values (safer for Read-Only)
self.log_signal.emit(" Settings loaded in READ-ONLY mode.")
else:
# Multiple profiles or load disabled
self.update_settings_read_only_mode = False
self.override_update_profile_settings = False
# --- LOGIC END ---
self._start_batch_update_check(self.active_update_profiles_list)
def _start_download_of_batch_update(self):
"""
@@ -5442,8 +5524,13 @@ class DownloaderApp (QWidget ):
global PostProcessorWorker, download_from_api
worker_args_template = fetcher_args['worker_args_template']
logger_func = lambda msg: self.log_signal.emit(f"[Fetcher] {msg}")
def logger_func(msg):
try:
import sip
if not sip.isdeleted(self):
self.log_signal.emit(f"[Fetcher] {msg}")
except (RuntimeError, ImportError, AttributeError):
pass # Window is gone, ignore logging
try:
# This single call now handles all fetching logic, including 'Fetch First'.
post_generator = download_from_api(
@@ -5524,8 +5611,18 @@ class DownloaderApp (QWidget ):
permanent, history_data,
temp_filepath) = result_tuple
if temp_filepath: self.session_temp_files.append(temp_filepath)
if temp_filepath:
self.session_temp_files.append(temp_filepath)
# If Single PDF mode is enabled, we need to load the data
# from the temp file into memory for the final aggregation.
if self.single_pdf_setting:
try:
with open(temp_filepath, 'r', encoding='utf-8') as f:
post_content_data = json.load(f)
self.temp_pdf_content_list.append(post_content_data)
except Exception as e:
self.log_signal.emit(f"⚠️ Error reading temp file for PDF aggregation: {e}")
with self.downloaded_files_lock:
self.download_counter += downloaded
self.skip_counter += skipped
@@ -5551,47 +5648,73 @@ class DownloaderApp (QWidget ):
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames)
def _trigger_single_pdf_creation(self):
"""Reads temp files, sorts them by date, then creates the single PDF."""
self.log_signal.emit("="*40)
self.log_signal.emit("Creating single PDF from collected text files...")
posts_content_data = []
for temp_filepath in self.session_temp_files:
try:
with open(temp_filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
posts_content_data.append(data)
except Exception as e:
self.log_signal.emit(f" ⚠️ Could not read temp file '{temp_filepath}': {e}")
if not posts_content_data:
self.log_signal.emit(" No content was collected. Aborting PDF creation.")
"""
Triggers the creation of a single PDF from collected text content in a BACKGROUND THREAD.
"""
if not self.temp_pdf_content_list:
self.log_signal.emit("⚠️ No content collected for Single PDF.")
return
output_dir = self.dir_input.text().strip() or QStandardPaths.writableLocation(QStandardPaths.DownloadLocation)
default_filename = os.path.join(output_dir, "Consolidated_Content.pdf")
filepath, _ = QFileDialog.getSaveFileName(self, "Save Single PDF", default_filename, "PDF Files (*.pdf)")
# 1. Sort the content
self.log_signal.emit(" Sorting collected content for PDF...")
def sort_key(post):
p_date = post.get('published') or "0000-00-00"
a_date = post.get('added') or "0000-00-00"
pid = post.get('id') or "0"
return (p_date, a_date, pid)
if not filepath:
self.log_signal.emit(" Single PDF creation cancelled by user.")
return
sorted_content = sorted(self.temp_pdf_content_list, key=sort_key)
if not filepath.lower().endswith('.pdf'):
filepath += '.pdf'
# 2. Determine Filename
first_post = sorted_content[0]
creator_name = first_post.get('creator_name') or first_post.get('user') or "Unknown_Creator"
clean_creator = clean_folder_name(creator_name)
filename = f"[{clean_creator}] Complete_Collection.pdf"
# --- FIX 3: Corrected Fallback Logic ---
# Use the stored dir, or fall back to the text input in the UI, or finally the app root
base_dir = self.last_effective_download_dir
if not base_dir:
base_dir = self.dir_input.text().strip()
if not base_dir:
base_dir = self.app_base_dir
output_path = os.path.join(base_dir, filename)
# ---------------------------------------
# 3. Get Options
font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
self.log_signal.emit(" Sorting collected posts by date (oldest first)...")
sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z'))
# Get 'Add Info Page' preference
add_info = True
if hasattr(self, 'more_options_dialog') and self.more_options_dialog:
add_info = self.more_options_dialog.get_add_info_state()
elif hasattr(self, 'add_info_in_pdf_setting'):
add_info = self.add_info_in_pdf_setting
create_single_pdf_from_content(
sorted_content,
filepath,
font_path,
add_info_page=self.add_info_in_pdf_setting, # Pass the flag here
logger=self.log_signal.emit
# 4. START THE THREAD
self.pdf_thread = PdfGenerationThread(
posts_data=sorted_content,
output_filename=output_path,
font_path=font_path,
add_info_page=add_info,
logger_func=self.log_signal.emit
)
self.log_signal.emit("="*40)
self.pdf_thread.finished_signal.connect(self._on_pdf_generation_finished)
self.pdf_thread.start()
def _on_pdf_generation_finished(self, success, message):
"""Callback for when the PDF thread is done."""
if success:
self.log_signal.emit(f"{message}")
QMessageBox.information(self, "PDF Created", message)
else:
self.log_signal.emit(f"❌ PDF Creation Error: {message}")
QMessageBox.warning(self, "PDF Error", f"Could not create PDF: {message}")
# Optional: Clear the temp list now that we are done
self.temp_pdf_content_list = []
def _add_to_history_candidates(self, history_data):
"""Adds processed post data to the history candidates list and updates the creator profile."""
@@ -6513,11 +6636,11 @@ class DownloaderApp (QWidget ):
# Look up the name in the cache, falling back to the ID if not found.
creator_name = self.creator_name_cache.get((service, user_id), user_id)
# Add the new 'creator_name' key to the format_values dictionary.
format_values = {
'id': str(job_details.get('original_post_id_for_log', '')),
'user': user_id,
'creator_name': creator_name, # <-- ADDED
'creator_name': creator_name,
'service': str(job_details.get('service', '')),
'title': post_title,
'name': base,
@@ -6952,7 +7075,6 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(f" Fetched a total of {len(all_posts_from_api)} posts from the server.")
# CORRECTED LINE: Assign the list directly without re-filtering
self.new_posts_for_update = all_posts_from_api
if not self.new_posts_for_update:
@@ -6980,7 +7102,6 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(f" Update session will save to base folder: {base_download_dir_from_ui}")
raw_character_filters_text = self.character_input.text().strip()
# FIX: Parse both filters and commands from the input string
parsed_character_filter_objects, download_commands = self._parse_character_filters(raw_character_filters_text)
try:
@@ -7058,11 +7179,7 @@ class DownloaderApp (QWidget ):
'single_pdf_mode': self.single_pdf_setting,
'project_root_dir': self.app_base_dir,
'processed_post_ids': list(self.active_update_profile['processed_post_ids']),
# FIX: Use the parsed commands dictionary to get the sfp_threshold
'sfp_threshold': download_commands.get('sfp_threshold'),
# FIX: Add all the missing keys
'date_prefix_format': self.date_prefix_format,
'domain_override': download_commands.get('domain_override'),
'archive_only_mode': download_commands.get('archive_only', False),
@@ -7095,11 +7212,9 @@ class DownloaderApp (QWidget ):
dialog = EmptyPopupDialog(self.user_data_path, self)
if dialog.exec_() == QDialog.Accepted:
# --- START OF MODIFICATION ---
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
self.active_update_profiles_list = dialog.update_profiles_list
# --- NEW LOGIC: Check if user wants to load settings into UI ---
load_settings_requested = getattr(dialog, 'load_settings_into_ui_requested', False)
self.override_update_profile_settings = load_settings_requested
@@ -7116,7 +7231,7 @@ class DownloaderApp (QWidget ):
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
self._start_batch_update_check(self.active_update_profiles_list)
# --- END OF MODIFICATION ---
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None # Ensure single update mode is off
@@ -7365,17 +7480,13 @@ class DownloaderApp (QWidget ):
should_create_artist_folder = False
# --- Check for popup selection scope ---
if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS:
should_create_artist_folder = True
# --- Check for global "Artist Folders" scope ---
should_create_artist_folder = True
elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS:
should_create_artist_folder = True
# --- NEW: Check for forced folder flag from batch ---
if self.current_processing_favorite_item_info.get('force_artist_folder'):
should_create_artist_folder = True
# ---------------------------------------------------
if should_create_artist_folder and main_download_dir:
folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
@@ -7392,4 +7503,36 @@ class DownloaderApp (QWidget ):
if not success_starting_download:
self.log_signal.emit(f"⚠️ Failed to initiate download for '{item_display_name}'. Skipping and moving to the next item in queue.")
QTimer.singleShot(100, self._process_next_favorite_download)
QTimer.singleShot(100, self._process_next_favorite_download)
class PdfGenerationThread(QThread):
finished_signal = pyqtSignal(bool, str) # success, message
def __init__(self, posts_data, output_filename, font_path, add_info_page, logger_func):
super().__init__()
self.posts_data = posts_data
self.output_filename = output_filename
self.font_path = font_path
self.add_info_page = add_info_page
self.logger_func = logger_func
def run(self):
try:
from .dialogs.SinglePDF import create_single_pdf_from_content
self.logger_func("📄 Background Task: Generating Single PDF... (This may take a while)")
success = create_single_pdf_from_content(
self.posts_data,
self.output_filename,
self.font_path,
self.add_info_page,
logger=self.logger_func
)
if success:
self.finished_signal.emit(True, f"PDF Saved: {os.path.basename(self.output_filename)}")
else:
self.finished_signal.emit(False, "PDF generation failed.")
except Exception as e:
self.finished_signal.emit(False, str(e))