mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77bd428b91 | ||
|
|
4bf57eb752 | ||
|
|
de202961a0 | ||
|
|
e806b6de66 | ||
|
|
cb8dd3b7f3 | ||
|
|
5a8c151c97 | ||
|
|
50ba60a461 | ||
|
|
23521e7060 | ||
|
|
f9c504b936 | ||
|
|
efa0abd0f1 | ||
|
|
7d76d00470 | ||
|
|
1494d3f456 | ||
|
|
675646e763 | ||
|
|
611e892576 | ||
|
|
23fd7f0714 | ||
|
|
cfcd800a49 | ||
|
|
24acec2dc3 | ||
|
|
b5b6c1bc46 | ||
|
|
67faea0992 |
@@ -68,6 +68,15 @@ DISCORD_TOKEN_KEY = "discord/token"
|
||||
|
||||
POST_DOWNLOAD_ACTION_KEY = "postDownloadAction"
|
||||
|
||||
|
||||
# --- Proxy / Network Keys ---
|
||||
PROXY_ENABLED_KEY = "proxy/enabled"
|
||||
PROXY_HOST_KEY = "proxy/host"
|
||||
PROXY_PORT_KEY = "proxy/port"
|
||||
PROXY_USERNAME_KEY = "proxy/username"
|
||||
PROXY_PASSWORD_KEY = "proxy/password"
|
||||
PROXY_TYPE_KEY = "proxy_type"
|
||||
|
||||
# --- UI Constants and Identifiers ---
|
||||
HTML_PREFIX = "<!HTML!>"
|
||||
LOG_DISPLAY_LINKS = "links"
|
||||
|
||||
@@ -10,10 +10,9 @@ import queue
|
||||
def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func):
|
||||
"""
|
||||
Orchestrates the download process using a producer-consumer model.
|
||||
The main thread scrapes image URLs and puts them in a queue.
|
||||
A pool of worker threads consumes from the queue to download images concurrently.
|
||||
"""
|
||||
scraper = cloudscraper.create_scraper()
|
||||
all_failed_files = [] # Track all failures across chapters
|
||||
|
||||
try:
|
||||
progress_callback(" [Hentai2Read] Scraping series page for all metadata...")
|
||||
@@ -39,8 +38,7 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
|
||||
os.makedirs(final_save_path, exist_ok=True)
|
||||
|
||||
# This function now scrapes and downloads simultaneously
|
||||
dl_count, skip_count = _process_and_download_chapter(
|
||||
dl_count, skip_count, chapter_failures = _process_and_download_chapter(
|
||||
chapter_url=chapter['url'],
|
||||
save_path=final_save_path,
|
||||
scraper=scraper,
|
||||
@@ -51,9 +49,22 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
total_downloaded_count += dl_count
|
||||
total_skipped_count += skip_count
|
||||
|
||||
if chapter_failures:
|
||||
all_failed_files.extend(chapter_failures)
|
||||
|
||||
overall_progress_callback(total_chapters, idx + 1)
|
||||
if check_pause_func(): break
|
||||
|
||||
# --- FINAL SUMMARY OF FAILURES ---
|
||||
if all_failed_files:
|
||||
progress_callback("\n" + "="*40)
|
||||
progress_callback(f"❌ SUMMARY: {len(all_failed_files)} files failed permanently after 10 retries:")
|
||||
for fail_msg in all_failed_files:
|
||||
progress_callback(f" • {fail_msg}")
|
||||
progress_callback("="*40 + "\n")
|
||||
else:
|
||||
progress_callback("\n✅ All chapters processed successfully with no permanent failures.")
|
||||
|
||||
return total_downloaded_count, total_skipped_count
|
||||
|
||||
except Exception as e:
|
||||
@@ -63,9 +74,8 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
"""
|
||||
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
|
||||
Includes a retry mechanism for the initial connection.
|
||||
"""
|
||||
max_retries = 4 # Total number of attempts (1 initial + 3 retries)
|
||||
max_retries = 4
|
||||
last_exception = None
|
||||
soup = None
|
||||
|
||||
@@ -77,8 +87,6 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
response = scraper.get(start_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# If successful, clear exception and break the loop
|
||||
last_exception = None
|
||||
break
|
||||
|
||||
@@ -86,8 +94,8 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
last_exception = e
|
||||
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s
|
||||
continue # Try again
|
||||
time.sleep(2 * (attempt + 1))
|
||||
continue
|
||||
|
||||
if last_exception:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
|
||||
@@ -96,23 +104,36 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
try:
|
||||
series_title = "Unknown Series"
|
||||
artist_name = None
|
||||
metadata_list = soup.select_one("ul.list.list-simple-mini")
|
||||
|
||||
if metadata_list:
|
||||
first_li = metadata_list.find('li', recursive=False)
|
||||
if first_li and not first_li.find('a'):
|
||||
series_title = first_li.get_text(strip=True)
|
||||
|
||||
# 1. Try fetching Title
|
||||
title_tag = soup.select_one("h3.block-title a")
|
||||
if title_tag:
|
||||
series_title = title_tag.get_text(strip=True)
|
||||
else:
|
||||
meta_title = soup.select_one("meta[property='og:title']")
|
||||
if meta_title:
|
||||
series_title = meta_title.get("content", "Unknown Series").replace(" - Hentai2Read", "")
|
||||
|
||||
# 2. Try fetching Artist
|
||||
metadata_list = soup.select_one("ul.list.list-simple-mini")
|
||||
if metadata_list:
|
||||
for b_tag in metadata_list.find_all('b'):
|
||||
label = b_tag.get_text(strip=True)
|
||||
if label in ("Artist", "Author"):
|
||||
if "Artist" in label or "Author" in label:
|
||||
a_tag = b_tag.find_next_sibling('a')
|
||||
if a_tag:
|
||||
artist_name = a_tag.get_text(strip=True)
|
||||
if label == "Artist":
|
||||
break
|
||||
break
|
||||
|
||||
top_level_folder_name = artist_name if artist_name else series_title
|
||||
if not artist_name:
|
||||
artist_link = soup.find('a', href=re.compile(r'/hentai-list/artist/'))
|
||||
if artist_link:
|
||||
artist_name = artist_link.get_text(strip=True)
|
||||
|
||||
if artist_name:
|
||||
top_level_folder_name = f"{artist_name} - {series_title}"
|
||||
else:
|
||||
top_level_folder_name = series_title
|
||||
|
||||
chapter_links = soup.select("div.media a.pull-left.font-w600")
|
||||
if not chapter_links:
|
||||
@@ -124,7 +145,7 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
]
|
||||
chapters_to_process.reverse()
|
||||
|
||||
progress_callback(f" [Hentai2Read] ✅ Found Artist/Series: '{top_level_folder_name}'")
|
||||
progress_callback(f" [Hentai2Read] ✅ Found Metadata: '{top_level_folder_name}'")
|
||||
progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
|
||||
|
||||
return top_level_folder_name, chapters_to_process
|
||||
@@ -136,69 +157,102 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
|
||||
"""
|
||||
Uses a producer-consumer pattern to download a chapter.
|
||||
The main thread (producer) scrapes URLs one by one.
|
||||
Worker threads (consumers) download the URLs as they are found.
|
||||
Includes RETRY LOGIC and ACTIVE LOGGING.
|
||||
"""
|
||||
task_queue = queue.Queue()
|
||||
num_download_threads = 8
|
||||
|
||||
download_stats = {'downloaded': 0, 'skipped': 0}
|
||||
failed_files_list = []
|
||||
|
||||
def downloader_worker():
|
||||
"""The function that each download thread will run."""
|
||||
worker_scraper = cloudscraper.create_scraper()
|
||||
while True:
|
||||
try:
|
||||
# Get a task from the queue
|
||||
task = task_queue.get()
|
||||
# The sentinel value to signal the end
|
||||
if task is None:
|
||||
break
|
||||
|
||||
filepath, img_url = task
|
||||
if os.path.exists(filepath):
|
||||
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'")
|
||||
download_stats['skipped'] += 1
|
||||
else:
|
||||
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...")
|
||||
task = task_queue.get()
|
||||
if task is None:
|
||||
task_queue.task_done()
|
||||
break
|
||||
|
||||
filepath, img_url = task
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
# We log skips to show it's checking files
|
||||
progress_callback(f" -> Skip (Exists): '{filename}'")
|
||||
download_stats['skipped'] += 1
|
||||
task_queue.task_done()
|
||||
continue
|
||||
|
||||
# --- RETRY LOGIC START ---
|
||||
success = False
|
||||
# UNCOMMENTED: Log the start of download so you see activity
|
||||
progress_callback(f" Downloading: '{filename}'...")
|
||||
|
||||
for attempt in range(10): # Try 10 times
|
||||
try:
|
||||
if attempt > 0:
|
||||
progress_callback(f" ⚠️ Retrying '{filename}' (Attempt {attempt+1}/10)...")
|
||||
time.sleep(2)
|
||||
|
||||
response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
|
||||
response.raise_for_status()
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
download_stats['downloaded'] += 1
|
||||
except Exception as e:
|
||||
progress_callback(f" ❌ Download failed for task. Error: {e}")
|
||||
download_stats['skipped'] += 1
|
||||
finally:
|
||||
task_queue.task_done()
|
||||
success = True
|
||||
# UNCOMMENTED: Log success
|
||||
progress_callback(f" ✅ Downloaded: '{filename}'")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if attempt == 9:
|
||||
progress_callback(f" ❌ Failed '{filename}' after 10 attempts: {e}")
|
||||
|
||||
if not success:
|
||||
failed_files_list.append(f"{filename} (Chapter: {os.path.basename(save_path)})")
|
||||
# Clean up empty file if failed
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except OSError: pass
|
||||
|
||||
task_queue.task_done()
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
|
||||
for _ in range(num_download_threads):
|
||||
executor.submit(downloader_worker)
|
||||
|
||||
page_number = 1
|
||||
progress_callback(" [Hentai2Read] Scanning pages...") # Initial log
|
||||
|
||||
while True:
|
||||
if check_pause_func(): break
|
||||
if page_number > 300: # Safety break
|
||||
if page_number > 300:
|
||||
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
|
||||
break
|
||||
|
||||
# Log occasionally to show scanning is alive
|
||||
if page_number % 10 == 0:
|
||||
progress_callback(f" [Hentai2Read] Scanned {page_number} pages so far...")
|
||||
|
||||
page_url_to_check = f"{chapter_url}{page_number}/"
|
||||
try:
|
||||
page_response = None
|
||||
page_last_exception = None
|
||||
for page_attempt in range(3): # 3 attempts for sub-pages
|
||||
for page_attempt in range(3):
|
||||
try:
|
||||
page_response = scraper.get(page_url_to_check, timeout=30)
|
||||
page_last_exception = None
|
||||
break
|
||||
except Exception as e:
|
||||
page_last_exception = e
|
||||
time.sleep(1) # Short delay for page scraping retries
|
||||
time.sleep(1)
|
||||
|
||||
if page_last_exception:
|
||||
raise page_last_exception # Give up after 3 tries
|
||||
raise page_last_exception
|
||||
|
||||
if page_response.history or page_response.status_code != 200:
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
|
||||
@@ -209,7 +263,7 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
img_src = img_tag.get("src") if img_tag else None
|
||||
|
||||
if not img_tag or img_src == "https://static.hentai.direct/hentai":
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected (Last page reached at {page_number}).")
|
||||
break
|
||||
|
||||
normalized_img_src = urljoin(page_response.url, img_src)
|
||||
@@ -220,15 +274,19 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
task_queue.put((filepath, normalized_img_src))
|
||||
|
||||
page_number += 1
|
||||
time.sleep(0.1) # Small delay between scraping pages
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
|
||||
break
|
||||
|
||||
# Signal workers to exit
|
||||
for _ in range(num_download_threads):
|
||||
task_queue.put(None)
|
||||
|
||||
# Wait for all tasks to complete
|
||||
task_queue.join()
|
||||
executor.shutdown(wait=True)
|
||||
|
||||
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
|
||||
return download_stats['downloaded'], download_stats['skipped']
|
||||
progress_callback(f" Chapter complete. Processed {page_number - 1} images.")
|
||||
|
||||
return download_stats['downloaded'], download_stats['skipped'], failed_files_list
|
||||
@@ -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:
|
||||
|
||||
@@ -6,11 +6,13 @@ import requests
|
||||
import cloudscraper
|
||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ..config.constants import (
|
||||
STYLE_DATE_POST_TITLE
|
||||
STYLE_DATE_POST_TITLE,
|
||||
STYLE_DATE_BASED,
|
||||
STYLE_POST_TITLE_GLOBAL_NUMBERING
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -23,7 +25,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||
time.sleep(0.5)
|
||||
logger(" Post fetching resumed.")
|
||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
|
||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags,content"
|
||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||
|
||||
max_retries = 3
|
||||
@@ -38,11 +40,14 @@ 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:
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
response.encoding = 'utf-8'
|
||||
return response.json()
|
||||
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()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Handle 403 error on the FIRST page as a rate limit/block
|
||||
@@ -79,18 +84,22 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
|
||||
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
||||
|
||||
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
|
||||
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None, proxies=None):
|
||||
"""
|
||||
--- MODIFIED FUNCTION ---
|
||||
Fetches the full data, including the 'content' field, for a single post using cloudscraper.
|
||||
"""
|
||||
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}...")
|
||||
|
||||
scraper = cloudscraper.create_scraper()
|
||||
|
||||
# FIX: Ensure scraper session is closed after use
|
||||
scraper = None
|
||||
try:
|
||||
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict)
|
||||
scraper = cloudscraper.create_scraper()
|
||||
# Keep the 300s read timeout for both, but increase connect timeout for proxies
|
||||
request_timeout = (30, 300) if proxies else (15, 300)
|
||||
|
||||
response = scraper.get(post_api_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
full_post_data = response.json()
|
||||
@@ -104,9 +113,12 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
|
||||
except Exception as e:
|
||||
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
|
||||
return None
|
||||
finally:
|
||||
if scraper:
|
||||
scraper.close()
|
||||
|
||||
|
||||
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
||||
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None, proxies=None):
|
||||
"""Fetches all comments for a specific post."""
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
raise RuntimeError("Comment fetch operation cancelled by user.")
|
||||
@@ -115,10 +127,12 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
|
||||
logger(f" Fetching comments: {comments_api_url}")
|
||||
|
||||
try:
|
||||
response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
response.encoding = 'utf-8'
|
||||
return response.json()
|
||||
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()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Error fetching comments for post {post_id}: {e}")
|
||||
except ValueError as e:
|
||||
@@ -138,7 +152,8 @@ def download_from_api(
|
||||
app_base_dir=None,
|
||||
manga_filename_style_for_sort_check=None,
|
||||
processed_post_ids=None,
|
||||
fetch_all_first=False
|
||||
fetch_all_first=False,
|
||||
proxies=None
|
||||
):
|
||||
parsed_input_url_for_domain = urlparse(api_url_input)
|
||||
api_domain = parsed_input_url_for_domain.netloc
|
||||
@@ -174,10 +189,13 @@ 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:
|
||||
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
|
||||
direct_response.raise_for_status()
|
||||
direct_response.encoding = 'utf-8'
|
||||
direct_post_data = direct_response.json()
|
||||
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()
|
||||
|
||||
if isinstance(direct_post_data, list) and direct_post_data:
|
||||
direct_post_data = direct_post_data[0]
|
||||
if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
|
||||
@@ -200,12 +218,23 @@ 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).")
|
||||
|
||||
is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id
|
||||
# --- 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
|
||||
not target_post_id
|
||||
)
|
||||
|
||||
should_fetch_all = fetch_all_first or is_manga_mode_fetch_all_and_sort_oldest_first
|
||||
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/posts"
|
||||
page_size = 50
|
||||
|
||||
if is_manga_mode_fetch_all_and_sort_oldest_first:
|
||||
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
|
||||
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check} - Oldest First Sort Active): Fetching all posts to sort by date...")
|
||||
all_posts_for_manga_mode = []
|
||||
current_offset_manga = 0
|
||||
if start_page and start_page > 1:
|
||||
@@ -232,7 +261,7 @@ def download_from_api(
|
||||
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
|
||||
break
|
||||
try:
|
||||
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
|
||||
if not isinstance(posts_batch_manga, list):
|
||||
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
|
||||
break
|
||||
@@ -300,8 +329,9 @@ def download_from_api(
|
||||
yield all_posts_for_manga_mode[i:i + page_size]
|
||||
return
|
||||
|
||||
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check == STYLE_DATE_POST_TITLE):
|
||||
logger(f" Manga Mode (Style: {STYLE_DATE_POST_TITLE}): Processing posts in default API order (newest first).")
|
||||
# Log specific message for styles that are in Manga Mode but NOT sorting (Streaming)
|
||||
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check not in styles_requiring_fetch_all):
|
||||
logger(f" Renaming Mode (Style: {manga_filename_style_for_sort_check}): Processing posts in default API order (Streaming).")
|
||||
|
||||
current_page_num = 1
|
||||
current_offset = 0
|
||||
@@ -311,7 +341,6 @@ def download_from_api(
|
||||
current_page_num = start_page
|
||||
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
|
||||
|
||||
# --- START OF MODIFIED BLOCK ---
|
||||
while True:
|
||||
if pause_event and pause_event.is_set():
|
||||
logger(" Post fetching loop paused...")
|
||||
@@ -334,8 +363,7 @@ def download_from_api(
|
||||
break
|
||||
|
||||
try:
|
||||
# 1. Fetch the raw batch of posts
|
||||
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
|
||||
if not isinstance(raw_posts_batch, list):
|
||||
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||
break
|
||||
@@ -350,7 +378,6 @@ def download_from_api(
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
|
||||
if not raw_posts_batch:
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
|
||||
@@ -359,9 +386,8 @@ def download_from_api(
|
||||
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
|
||||
else:
|
||||
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
|
||||
break # This break is now correct.
|
||||
break
|
||||
|
||||
# 3. Filter the batch against processed IDs
|
||||
posts_batch_to_yield = raw_posts_batch
|
||||
original_count = len(raw_posts_batch)
|
||||
|
||||
@@ -371,25 +397,17 @@ def download_from_api(
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
|
||||
# 4. Process the *filtered* batch
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
# Still searching for a specific post
|
||||
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
|
||||
if matching_post:
|
||||
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
|
||||
yield [matching_post]
|
||||
processed_target_post_flag = True
|
||||
elif not target_post_id:
|
||||
# Downloading a creator feed
|
||||
if posts_batch_to_yield:
|
||||
# We found new posts on this page, yield them
|
||||
yield posts_batch_to_yield
|
||||
elif original_count > 0:
|
||||
# We found 0 new posts, but the page *did* have posts (they were just skipped).
|
||||
# Log this and continue to the next page.
|
||||
logger(f" No new posts found on page {current_page_num}. Checking next page...")
|
||||
# If original_count was 0, the `if not raw_posts_batch:` check
|
||||
# already caught it and broke the loop.
|
||||
|
||||
if processed_target_post_flag:
|
||||
break
|
||||
@@ -397,7 +415,6 @@ def download_from_api(
|
||||
current_offset += page_size
|
||||
current_page_num += 1
|
||||
time.sleep(0.6)
|
||||
# --- END OF MODIFIED BLOCK ---
|
||||
|
||||
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
|
||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||
193
src/core/deviantart_client.py
Normal file
193
src/core/deviantart_client.py
Normal file
@@ -0,0 +1,193 @@
|
||||
import requests
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
|
||||
class DeviantArtClient:
|
||||
# Public Client Credentials
|
||||
CLIENT_ID = "5388"
|
||||
CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1"
|
||||
BASE_API = "https://www.deviantart.com/api/v1/oauth2"
|
||||
|
||||
# 1. Accept proxies in init
|
||||
def __init__(self, logger_func=print, proxies=None):
|
||||
self.session = requests.Session()
|
||||
|
||||
# 2. Configure Session with Proxy & SSL settings immediately
|
||||
if proxies:
|
||||
self.session.proxies.update(proxies)
|
||||
self.session.verify = False # Ignore SSL for proxies
|
||||
self.proxies_enabled = True
|
||||
else:
|
||||
self.proxies_enabled = False
|
||||
|
||||
self.session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
})
|
||||
self.access_token = None
|
||||
self.logger = logger_func
|
||||
|
||||
# --- DEDUPLICATION LOGIC ---
|
||||
self.logged_waits = set()
|
||||
self.log_lock = threading.Lock()
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticates using client credentials flow."""
|
||||
try:
|
||||
url = "https://www.deviantart.com/oauth2/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.CLIENT_ID,
|
||||
"client_secret": self.CLIENT_SECRET
|
||||
}
|
||||
# 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")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger(f"DA Auth Error: {e}")
|
||||
return False
|
||||
|
||||
def _api_call(self, endpoint, params=None):
|
||||
if not self.access_token:
|
||||
if not self.authenticate():
|
||||
raise Exception("Authentication failed")
|
||||
|
||||
url = f"{self.BASE_API}{endpoint}"
|
||||
params = params or {}
|
||||
params['access_token'] = self.access_token
|
||||
params['mature_content'] = 'true'
|
||||
|
||||
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=req_timeout)
|
||||
|
||||
# 429: Rate Limit
|
||||
if resp.status_code == 429:
|
||||
retry_after = resp.headers.get('Retry-After')
|
||||
if retry_after:
|
||||
sleep_time = int(retry_after) + 2 # Add buffer
|
||||
else:
|
||||
# 5. Increase default wait time for 429s
|
||||
sleep_time = 15
|
||||
|
||||
self._log_once(sleep_time, f" [DeviantArt] ⚠️ Rate limit (429). Sleeping {sleep_time}s...")
|
||||
time.sleep(sleep_time)
|
||||
continue
|
||||
|
||||
# 401: Token Expired (Refresh and Retry)
|
||||
if resp.status_code == 401:
|
||||
self.logger(" [DeviantArt] Token expired. Refreshing...")
|
||||
if self.authenticate():
|
||||
params['access_token'] = self.access_token
|
||||
continue
|
||||
else:
|
||||
raise Exception("Failed to refresh token")
|
||||
|
||||
if 400 <= resp.status_code < 500:
|
||||
resp.raise_for_status()
|
||||
|
||||
if 500 <= resp.status_code < 600:
|
||||
resp.raise_for_status()
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
with self.log_lock:
|
||||
self.logged_waits.clear()
|
||||
|
||||
return resp.json()
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response is not None and 400 <= e.response.status_code < 500:
|
||||
raise e
|
||||
pass
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if retries < max_retries:
|
||||
self._log_once("conn_error", f" [DeviantArt] Connection error: {e}. Retrying...")
|
||||
time.sleep(backoff_delay)
|
||||
retries += 1
|
||||
continue
|
||||
raise e
|
||||
|
||||
def _log_once(self, key, message):
|
||||
"""Helper to avoid spamming the same log message during loops."""
|
||||
should_log = False
|
||||
with self.log_lock:
|
||||
if key not in self.logged_waits:
|
||||
self.logged_waits.add(key)
|
||||
should_log = True
|
||||
if should_log:
|
||||
self.logger(message)
|
||||
|
||||
def get_deviation_uuid(self, url):
|
||||
"""Scrapes the deviation page to find the UUID."""
|
||||
try:
|
||||
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)
|
||||
match = re.search(r'-(\d+)$', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception as e:
|
||||
self.logger(f"Error scraping UUID: {e}")
|
||||
return None
|
||||
|
||||
def get_deviation_content(self, uuid):
|
||||
"""Fetches download info."""
|
||||
try:
|
||||
data = self._api_call(f"/deviation/download/{uuid}")
|
||||
if 'src' in data:
|
||||
return data
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
meta = self._api_call(f"/deviation/{uuid}")
|
||||
if 'content' in meta:
|
||||
return meta['content']
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_gallery_folder(self, username, offset=0, limit=24):
|
||||
"""Fetches items from a user's gallery."""
|
||||
return self._api_call("/gallery/all", {"username": username, "offset": offset, "limit": limit})
|
||||
|
||||
@staticmethod
|
||||
def extract_info_from_url(url):
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip('/')
|
||||
parts = path.split('/')
|
||||
|
||||
if len(parts) >= 3 and parts[1] == 'art':
|
||||
return 'post', parts[0], parts[2]
|
||||
elif len(parts) >= 2 and parts[1] == 'gallery':
|
||||
return 'gallery', parts[0], None
|
||||
elif len(parts) == 1:
|
||||
return 'gallery', parts[0], None
|
||||
|
||||
return None, None, None
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, Future, CancelledError
|
||||
from .api_client import download_from_api
|
||||
from .workers import PostProcessorWorker
|
||||
from ..config.constants import (
|
||||
@@ -84,8 +84,18 @@ class DownloadManager:
|
||||
|
||||
is_single_post = bool(config.get('target_post_id_from_initial_url'))
|
||||
use_multithreading = config.get('use_multithreading', True)
|
||||
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
|
||||
# --- FIXED LOGIC: Strict check for sequential fetch modes ---
|
||||
# Only "Date Based" and "Title + Global Numbering" require fetching the full list first.
|
||||
# "Custom", "Date + Title", "Original Name", and "Post ID" will now use the pool (streaming).
|
||||
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
|
||||
is_manga_sequential = (
|
||||
config.get('manga_mode_active') and
|
||||
config.get('manga_filename_style') in sequential_styles
|
||||
)
|
||||
|
||||
# If it is NOT a strictly sequential manga mode, we use the pool (fetch-as-we-go)
|
||||
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
|
||||
|
||||
if should_use_multithreading_for_posts:
|
||||
@@ -97,12 +107,34 @@ class DownloadManager:
|
||||
fetcher_thread.start()
|
||||
else:
|
||||
# Single-threaded mode does not use the manager's complex logic
|
||||
self._log("ℹ️ Manager is handing off to a single-threaded worker...")
|
||||
self._log("ℹ️ Manager is handing off to a single-threaded worker (Sequential Mode)...")
|
||||
# The single-threaded worker will manage its own lifecycle and signals.
|
||||
# The manager's role for this session is effectively over.
|
||||
self.is_running = False # Allow another session to start if needed
|
||||
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)})
|
||||
|
||||
def _get_proxies_from_config(self, config):
|
||||
"""Constructs the proxy dictionary from the config."""
|
||||
if not config.get('proxy_enabled'):
|
||||
return None
|
||||
|
||||
host = config.get('proxy_host')
|
||||
port = config.get('proxy_port')
|
||||
if not host or not port:
|
||||
return None
|
||||
|
||||
proxy_str = f"http://{host}:{port}"
|
||||
|
||||
# Add auth if provided
|
||||
user = config.get('proxy_username')
|
||||
password = config.get('proxy_password')
|
||||
if user and password:
|
||||
proxy_str = f"http://{user}:{password}@{host}:{port}"
|
||||
|
||||
return {
|
||||
"http": proxy_str,
|
||||
"https": proxy_str
|
||||
}
|
||||
|
||||
def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
|
||||
"""
|
||||
@@ -117,6 +149,9 @@ class DownloadManager:
|
||||
session_processed_ids = set(restore_data.get('processed_post_ids', [])) if restore_data else set()
|
||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||
processed_ids = session_processed_ids.union(profile_processed_ids)
|
||||
|
||||
# Helper to get proxies
|
||||
proxies = self._get_proxies_from_config(config)
|
||||
|
||||
if restore_data and 'all_posts_data' in restore_data:
|
||||
# This logic for session restore remains as it relies on a pre-fetched list
|
||||
@@ -132,127 +167,113 @@ class DownloadManager:
|
||||
return
|
||||
|
||||
for post_data in posts_to_process:
|
||||
if self.cancellation_event.is_set(): break
|
||||
worker = PostProcessorWorker(post_data, config, self.progress_queue)
|
||||
if self.cancellation_event.is_set():
|
||||
break
|
||||
|
||||
worker_args = self._map_config_to_worker_args(post_data, config)
|
||||
# Manually inject proxies here if _map_config_to_worker_args didn't catch it (though it should)
|
||||
worker_args['proxies'] = proxies
|
||||
|
||||
worker = PostProcessorWorker(**worker_args)
|
||||
future = self.thread_pool.submit(worker.process)
|
||||
future.add_done_callback(self._handle_future_result)
|
||||
self.active_futures.append(future)
|
||||
else:
|
||||
# --- START: REFACTORED STREAMING LOGIC ---
|
||||
# --- Streaming Logic ---
|
||||
if proxies:
|
||||
self._log(f" 🌐 Using Proxy: {config.get('proxy_host')}:{config.get('proxy_port')}")
|
||||
|
||||
post_generator = download_from_api(
|
||||
api_url_input=config['api_url'],
|
||||
logger=self._log,
|
||||
start_page=config.get('start_page'),
|
||||
end_page=config.get('end_page'),
|
||||
manga_mode=config.get('manga_mode_active', False),
|
||||
cancellation_event=self.cancellation_event,
|
||||
pause_event=self.pause_event,
|
||||
use_cookie=config.get('use_cookie', False),
|
||||
cookie_text=config.get('cookie_text', ''),
|
||||
selected_cookie_file=config.get('selected_cookie_file'),
|
||||
app_base_dir=config.get('app_base_dir'),
|
||||
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
|
||||
processed_post_ids=list(processed_ids)
|
||||
cookies_dict=None, # Cookie handling handled inside client if needed
|
||||
proxies=proxies # <--- NEW: Pass proxies to API client
|
||||
)
|
||||
|
||||
self.total_posts = 0
|
||||
self.processed_posts = 0
|
||||
|
||||
# Process posts in batches as they are yielded by the API client
|
||||
for batch in post_generator:
|
||||
for post_batch in post_generator:
|
||||
if self.cancellation_event.is_set():
|
||||
self._log(" Post fetching cancelled.")
|
||||
break
|
||||
|
||||
# Filter out any posts that might have been processed since the start
|
||||
posts_in_batch_to_process = [p for p in batch if p.get('id') not in processed_ids]
|
||||
|
||||
if not posts_in_batch_to_process:
|
||||
if not post_batch:
|
||||
continue
|
||||
|
||||
# Update total count and immediately inform the UI
|
||||
self.total_posts += len(posts_in_batch_to_process)
|
||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||
new_posts_batch = [p for p in post_batch if p.get('id') not in processed_ids]
|
||||
|
||||
if not new_posts_batch:
|
||||
continue
|
||||
|
||||
for post_data in posts_in_batch_to_process:
|
||||
if self.cancellation_event.is_set(): break
|
||||
worker = PostProcessorWorker(post_data, config, self.progress_queue)
|
||||
# Update total posts dynamically as we find them
|
||||
self.total_posts += len(new_posts_batch)
|
||||
|
||||
for post_data in new_posts_batch:
|
||||
if self.cancellation_event.is_set():
|
||||
break
|
||||
|
||||
# MAPPING CONFIG TO WORKER ARGS
|
||||
worker_args = self._map_config_to_worker_args(post_data, config)
|
||||
worker = PostProcessorWorker(**worker_args)
|
||||
|
||||
future = self.thread_pool.submit(worker.process)
|
||||
future.add_done_callback(self._handle_future_result)
|
||||
self.active_futures.append(future)
|
||||
|
||||
if self.total_posts == 0 and not self.cancellation_event.is_set():
|
||||
self._log("✅ No new posts found to process.")
|
||||
|
||||
# Small sleep to prevent UI freeze
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
|
||||
self._log(traceback.format_exc())
|
||||
self._log(f"❌ Critical Error in Fetcher Thread: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if self.thread_pool:
|
||||
self.thread_pool.shutdown(wait=True)
|
||||
self.is_running = False
|
||||
self._log("🏁 All processing tasks have completed or been cancelled.")
|
||||
self.progress_queue.put({
|
||||
'type': 'finished',
|
||||
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
|
||||
})
|
||||
self.is_running = False # Mark as not running so we can finish
|
||||
# The main window checks active futures, so we just exit this thread.
|
||||
|
||||
def _handle_future_result(self, future: Future):
|
||||
"""Callback executed when a worker task completes."""
|
||||
if self.cancellation_event.is_set():
|
||||
return
|
||||
|
||||
with threading.Lock(): # Protect shared counters
|
||||
self.processed_posts += 1
|
||||
try:
|
||||
if future.cancelled():
|
||||
self._log("⚠️ A post processing task was cancelled.")
|
||||
self.total_skips += 1
|
||||
else:
|
||||
result = future.result()
|
||||
(dl_count, skip_count, kept_originals,
|
||||
retryable, permanent, history) = result
|
||||
self.total_downloads += dl_count
|
||||
self.total_skips += skip_count
|
||||
self.all_kept_original_filenames.extend(kept_originals)
|
||||
if retryable:
|
||||
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
|
||||
if permanent:
|
||||
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
|
||||
if history:
|
||||
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
|
||||
post_id = history.get('post_id')
|
||||
if post_id and self.current_creator_profile_path:
|
||||
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
|
||||
if post_id not in profile_data.get('processed_post_ids', []):
|
||||
profile_data.setdefault('processed_post_ids', []).append(post_id)
|
||||
self._save_creator_profile(profile_data)
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"❌ Worker task resulted in an exception: {e}")
|
||||
self.total_skips += 1 # Count errored posts as skipped
|
||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||
def _map_config_to_worker_args(self, post_data, config):
|
||||
"""Helper to map the flat config dict to PostProcessorWorker arguments."""
|
||||
# Get proxy dict
|
||||
proxies = self._get_proxies_from_config(config)
|
||||
|
||||
# This mirrors the arguments in workers.py PostProcessorWorker.__init__
|
||||
return {
|
||||
'post_data': post_data,
|
||||
'download_root': config.get('output_dir'),
|
||||
'known_names': [], # If needed, pass KNOWN_NAMES or load them
|
||||
'filter_character_list': [], # Parsed filters if available in config
|
||||
'emitter': self.progress_queue,
|
||||
'unwanted_keywords': set(), # Parse if needed
|
||||
'filter_mode': config.get('filter_mode'),
|
||||
'skip_zip': config.get('skip_zip'),
|
||||
'use_subfolders': config.get('use_subfolders'),
|
||||
'use_post_subfolders': config.get('use_post_subfolders'),
|
||||
'target_post_id_from_initial_url': config.get('target_post_id_from_initial_url'),
|
||||
'custom_folder_name': config.get('custom_folder_name'),
|
||||
'compress_images': config.get('compress_images'),
|
||||
'download_thumbnails': config.get('download_thumbnails'),
|
||||
'service': config.get('service') or 'unknown',
|
||||
'user_id': config.get('user_id') or 'unknown',
|
||||
'pause_event': self.pause_event,
|
||||
'api_url_input': config.get('api_url'),
|
||||
'cancellation_event': self.cancellation_event,
|
||||
'downloaded_files': None,
|
||||
'downloaded_file_hashes': None,
|
||||
'downloaded_files_lock': None,
|
||||
'downloaded_file_hashes_lock': None,
|
||||
'manga_mode_active': config.get('manga_mode_active'),
|
||||
'manga_filename_style': config.get('manga_filename_style'),
|
||||
'manga_custom_filename_format': config.get('custom_manga_filename_format', "{published} {title}"),
|
||||
'manga_custom_date_format': config.get('manga_custom_date_format', "YYYY-MM-DD"),
|
||||
'use_multithreading': config.get('use_multithreading', True),
|
||||
'proxies': proxies, # <--- NEW: Pass proxies to worker
|
||||
}
|
||||
|
||||
def _setup_creator_profile(self, config):
|
||||
"""Prepares the path and loads data for the current creator's profile."""
|
||||
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
|
||||
if not self.current_creator_name_for_profile:
|
||||
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
|
||||
return {}
|
||||
|
||||
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
|
||||
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
os.makedirs(self.creator_profiles_dir, exist_ok=True)
|
||||
|
||||
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
|
||||
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
|
||||
|
||||
if os.path.exists(self.current_creator_profile_path):
|
||||
try:
|
||||
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
|
||||
# Extract name logic here or assume config has it
|
||||
self.current_creator_name_for_profile = "Unknown"
|
||||
# You should ideally extract name from URL or config here if available
|
||||
return {}
|
||||
|
||||
def _save_creator_profile(self, data):
|
||||
@@ -280,6 +301,33 @@ class DownloadManager:
|
||||
self.cancellation_event.set()
|
||||
|
||||
if self.thread_pool:
|
||||
self._log(" Signaling all worker threads to stop and shutting down pool...")
|
||||
self.thread_pool.shutdown(wait=False)
|
||||
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _handle_future_result(self, future):
|
||||
"""Callback for when a worker task finishes."""
|
||||
if self.active_futures:
|
||||
try:
|
||||
self.active_futures.remove(future)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = future.result()
|
||||
# result tuple: (download_count, skip_count, kept_original_filenames, ...)
|
||||
if result:
|
||||
self.total_downloads += result[0]
|
||||
self.total_skips += result[1]
|
||||
if len(result) > 3 and result[3]:
|
||||
# filename was kept original
|
||||
pass
|
||||
except CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self._log(f"❌ Worker Error: {e}")
|
||||
|
||||
self.processed_posts += 1
|
||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||
|
||||
if not self.active_futures and not self.is_running:
|
||||
self._log("✅ All tasks completed.")
|
||||
self.progress_queue.put({'type': 'worker_finished', 'payload': (self.total_downloads, self.total_skips, [], [])})
|
||||
@@ -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
|
||||
@@ -56,12 +56,13 @@ from ..utils.text_utils import (
|
||||
match_folders_from_title, match_folders_from_filename_enhanced
|
||||
)
|
||||
from ..config.constants import *
|
||||
from ..ui.dialogs.SinglePDF import create_individual_pdf
|
||||
|
||||
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<>:"/\\|?*\'\[\]]'
|
||||
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\']'
|
||||
cleaned_name = re.sub(illegal_chars_pattern, '', name)
|
||||
|
||||
cleaned_name = cleaned_name.strip(' .')
|
||||
@@ -132,6 +133,9 @@ class PostProcessorWorker:
|
||||
sfp_threshold=None,
|
||||
handle_unknown_mode=False,
|
||||
creator_name_cache=None,
|
||||
add_info_in_pdf=False,
|
||||
proxies=None
|
||||
|
||||
):
|
||||
self.post = post_data
|
||||
self.download_root = download_root
|
||||
@@ -205,6 +209,9 @@ class PostProcessorWorker:
|
||||
self.sfp_threshold = sfp_threshold
|
||||
self.handle_unknown_mode = handle_unknown_mode
|
||||
self.creator_name_cache = creator_name_cache
|
||||
self.add_info_in_pdf = add_info_in_pdf
|
||||
self.proxies = proxies
|
||||
|
||||
|
||||
if self.compress_images and Image is None:
|
||||
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
||||
@@ -256,7 +263,7 @@ class PostProcessorWorker:
|
||||
new_url = parsed_url._replace(netloc=new_domain).geturl()
|
||||
|
||||
try:
|
||||
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True) as resp:
|
||||
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True, proxies=self.proxies, verify=False) as resp:
|
||||
if resp.status_code == 200:
|
||||
return new_url
|
||||
except requests.RequestException:
|
||||
@@ -331,7 +338,8 @@ class PostProcessorWorker:
|
||||
api_original_filename_for_size_check = file_info.get('_original_name_for_log', file_info.get('name'))
|
||||
try:
|
||||
# Use a stream=True HEAD request to get headers without downloading the body
|
||||
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
|
||||
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True, proxies=self.proxies, verify=False) as head_response:
|
||||
|
||||
head_response.raise_for_status()
|
||||
content_length = head_response.headers.get('Content-Length')
|
||||
if content_length:
|
||||
@@ -665,7 +673,7 @@ class PostProcessorWorker:
|
||||
|
||||
current_url_to_try = file_url
|
||||
|
||||
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
|
||||
|
||||
if response.status_code == 403 and ('kemono.' in current_url_to_try or 'coomer.' in current_url_to_try):
|
||||
self.logger(f" ⚠️ Got 403 Forbidden for '{api_original_filename}'. Attempting subdomain rotation...")
|
||||
@@ -674,8 +682,7 @@ class PostProcessorWorker:
|
||||
self.logger(f" Retrying with new URL: {new_url}")
|
||||
file_url = new_url
|
||||
response.close() # Close the old response
|
||||
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
|
||||
|
||||
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
|
||||
response.raise_for_status()
|
||||
|
||||
# --- REVISED AND MOVED SIZE CHECK LOGIC ---
|
||||
@@ -974,6 +981,92 @@ class PostProcessorWorker:
|
||||
else:
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
|
||||
|
||||
def _get_manga_style_filename_for_post(self, post_title, original_ext):
|
||||
"""Generates a filename based on manga style, using post data."""
|
||||
if self.manga_filename_style == STYLE_POST_TITLE:
|
||||
cleaned_post_title_base = robust_clean_name(post_title.strip() if post_title and post_title.strip() else "post")
|
||||
return f"{cleaned_post_title_base}{original_ext}"
|
||||
|
||||
elif self.manga_filename_style == STYLE_CUSTOM:
|
||||
try:
|
||||
def format_date(date_str):
|
||||
if not date_str or 'NoDate' in date_str:
|
||||
return "NoDate"
|
||||
try:
|
||||
dt_obj = datetime.fromisoformat(date_str)
|
||||
strftime_format = self.manga_custom_date_format.replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
|
||||
return dt_obj.strftime(strftime_format)
|
||||
except (ValueError, TypeError):
|
||||
return date_str.split('T')[0]
|
||||
|
||||
service = self.service.lower()
|
||||
user_id = str(self.user_id)
|
||||
creator_name = self.creator_name_cache.get((service, user_id), user_id)
|
||||
|
||||
added_date = self.post.get('added')
|
||||
published_date = self.post.get('published')
|
||||
edited_date = self.post.get('edited')
|
||||
|
||||
format_values = {
|
||||
'id': str(self.post.get('id', '')),
|
||||
'user': user_id,
|
||||
'creator_name': creator_name,
|
||||
'service': self.service,
|
||||
'title': str(self.post.get('title', '')),
|
||||
'name': robust_clean_name(post_title), # Use post title as a fallback 'name'
|
||||
'added': format_date(added_date or published_date),
|
||||
'published': format_date(published_date),
|
||||
'edited': format_date(edited_date or published_date)
|
||||
}
|
||||
|
||||
custom_base_name = self.manga_custom_filename_format.format(**format_values)
|
||||
cleaned_custom_name = robust_clean_name(custom_base_name)
|
||||
|
||||
return f"{cleaned_custom_name}{original_ext}"
|
||||
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
self.logger(f"⚠️ Custom format error for text export: {e}. Falling back to post title.")
|
||||
return f"{robust_clean_name(post_title.strip() or 'untitled_post')}{original_ext}"
|
||||
|
||||
elif self.manga_filename_style == STYLE_DATE_POST_TITLE:
|
||||
published_date_str = self.post.get('published')
|
||||
added_date_str = self.post.get('added')
|
||||
formatted_date_str = "nodate"
|
||||
if published_date_str:
|
||||
try:
|
||||
formatted_date_str = published_date_str.split('T')[0]
|
||||
except Exception:
|
||||
pass
|
||||
elif added_date_str:
|
||||
try:
|
||||
formatted_date_str = added_date_str.split('T')[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cleaned_post_title_for_filename = robust_clean_name(post_title.strip() or "post")
|
||||
base_name_for_style = f"{formatted_date_str}_{cleaned_post_title_for_filename}"
|
||||
return f"{base_name_for_style}{original_ext}"
|
||||
|
||||
elif self.manga_filename_style == STYLE_POST_ID:
|
||||
post_id = str(self.post.get('id', 'unknown_id'))
|
||||
return f"{post_id}{original_ext}"
|
||||
|
||||
elif self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||
published_date_str = self.post.get('published') or self.post.get('added')
|
||||
formatted_date_str = "nodate"
|
||||
if published_date_str:
|
||||
try:
|
||||
formatted_date_str = published_date_str.split('T')[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Use post title as the name part, as there is no "original filename" for the text export.
|
||||
cleaned_post_title_base = robust_clean_name(post_title.strip() or "untitled_post")
|
||||
return f"{formatted_date_str}_{cleaned_post_title_base}{original_ext}"
|
||||
|
||||
# Default fallback
|
||||
return f"{robust_clean_name(post_title.strip() or 'untitled_post')}{original_ext}"
|
||||
|
||||
def process(self):
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
try:
|
||||
@@ -1011,8 +1104,8 @@ class PostProcessorWorker:
|
||||
'Referer': creator_page_url,
|
||||
'Accept': 'text/css'
|
||||
}
|
||||
cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain)
|
||||
full_post_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
|
||||
cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain)
|
||||
full_post_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies, proxies=self.proxies)
|
||||
if full_post_data:
|
||||
self.logger(" ✅ Full post data fetched successfully.")
|
||||
self.post = full_post_data
|
||||
@@ -1213,13 +1306,17 @@ class PostProcessorWorker:
|
||||
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.")
|
||||
api_domain_for_comments = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
|
||||
|
||||
# Fetch comments (Indented correctly now)
|
||||
comments_data = fetch_post_comments(
|
||||
api_domain_for_comments, self.service, self.user_id, post_id,
|
||||
headers, self.logger, self.cancellation_event, self.pause_event,
|
||||
cookies_dict=prepare_cookies_for_request(
|
||||
self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger
|
||||
)
|
||||
),
|
||||
proxies=self.proxies
|
||||
)
|
||||
|
||||
if comments_data:
|
||||
self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.")
|
||||
for comment_item_idx, comment_item in enumerate(comments_data):
|
||||
@@ -1246,8 +1343,8 @@ class PostProcessorWorker:
|
||||
except RuntimeError as e_fetch_comment:
|
||||
self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}")
|
||||
except Exception as e_generic_comment:
|
||||
self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}")
|
||||
self.logger(f" [Char Scope: Comments] Phase 2 Result: post_is_candidate_by_comment_char_match = {post_is_candidate_by_comment_char_match}")
|
||||
self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}")
|
||||
|
||||
else:
|
||||
self.logger(f" [Char Scope: Comments] Phase 2: Skipped comment check for post ID '{post_id}' because a file match already made it a candidate.")
|
||||
|
||||
@@ -1269,6 +1366,8 @@ class PostProcessorWorker:
|
||||
if self.filter_mode == 'text_only' and not self.extract_links_only:
|
||||
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
|
||||
post_title_lower = post_title.lower()
|
||||
|
||||
# --- Skip Words Check ---
|
||||
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
|
||||
for skip_word in self.skip_words_list:
|
||||
if skip_word.lower() in post_title_lower:
|
||||
@@ -1287,6 +1386,7 @@ class PostProcessorWorker:
|
||||
comments_data = []
|
||||
final_post_data = post_data
|
||||
|
||||
# --- Content Fetching ---
|
||||
if self.text_only_scope == 'content' and 'content' not in final_post_data:
|
||||
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
|
||||
parsed_url = urlparse(self.api_url_input)
|
||||
@@ -1304,6 +1404,8 @@ class PostProcessorWorker:
|
||||
api_domain = parsed_url.netloc
|
||||
comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event)
|
||||
if comments_data:
|
||||
# For TXT/DOCX export, we format comments here.
|
||||
# For PDF, we pass the raw list to the generator.
|
||||
comment_texts = []
|
||||
for comment in comments_data:
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
@@ -1335,23 +1437,43 @@ class PostProcessorWorker:
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
|
||||
# --- Metadata Preparation ---
|
||||
# Prepare all data needed for the info page or JSON dump
|
||||
service_str = self.service
|
||||
user_id_str = str(self.user_id)
|
||||
post_id_str = str(post_id)
|
||||
creator_key = (service_str.lower(), user_id_str)
|
||||
|
||||
# Resolve creator name using the cache passed from main_window
|
||||
creator_name = user_id_str
|
||||
if self.creator_name_cache:
|
||||
creator_name = self.creator_name_cache.get(creator_key, user_id_str)
|
||||
|
||||
common_content_data = {
|
||||
'title': post_title,
|
||||
'published': self.post.get('published') or self.post.get('added'),
|
||||
'service': service_str,
|
||||
'user': user_id_str,
|
||||
'id': post_id_str,
|
||||
'tags': self.post.get('tags'),
|
||||
'original_link': post_page_url,
|
||||
'creator_name': creator_name
|
||||
}
|
||||
|
||||
# --- Single PDF Mode (Save Temp JSON) ---
|
||||
if self.single_pdf_mode:
|
||||
content_data = {
|
||||
'title': post_title,
|
||||
'published': self.post.get('published') or self.post.get('added')
|
||||
}
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data:
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
content_data['comments'] = comments_data
|
||||
common_content_data['comments'] = comments_data
|
||||
else:
|
||||
if not cleaned_text.strip():
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
content_data['content'] = cleaned_text
|
||||
common_content_data['content'] = cleaned_text
|
||||
|
||||
temp_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -1359,7 +1481,7 @@ class PostProcessorWorker:
|
||||
temp_filepath = os.path.join(temp_dir, temp_filename)
|
||||
try:
|
||||
with open(temp_filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(content_data, f, indent=2)
|
||||
json.dump(common_content_data, f, indent=2)
|
||||
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
|
||||
result_tuple = (0, 0, [], [], [], None, temp_filepath)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
@@ -1369,82 +1491,67 @@ class PostProcessorWorker:
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
|
||||
# --- Individual File Mode ---
|
||||
else:
|
||||
file_extension = self.text_export_format
|
||||
txt_filename = clean_filename(post_title) + f".{file_extension}"
|
||||
txt_filename = ""
|
||||
|
||||
if self.manga_mode_active:
|
||||
txt_filename = self._get_manga_style_filename_for_post(post_title, f".{file_extension}")
|
||||
self.logger(f" ℹ️ Applying Renaming Mode. Generated filename: '{txt_filename}'")
|
||||
else:
|
||||
txt_filename = clean_filename(post_title) + f".{file_extension}"
|
||||
|
||||
final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename)
|
||||
|
||||
try:
|
||||
os.makedirs(determined_post_save_path_for_history, exist_ok=True)
|
||||
base, ext = os.path.splitext(final_save_path)
|
||||
base, ext = os.path.splitext(final_save_path)
|
||||
|
||||
counter = 1
|
||||
while os.path.exists(final_save_path):
|
||||
final_save_path = f"{base}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
# --- PDF Generation ---
|
||||
if file_extension == 'pdf':
|
||||
if FPDF:
|
||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
||||
pdf = PDF()
|
||||
base_path = self.project_root_dir
|
||||
font_path = ""
|
||||
bold_font_path = ""
|
||||
|
||||
if base_path:
|
||||
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
default_font_family = 'DejaVu'
|
||||
except Exception as font_error:
|
||||
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(0, 10, post_title)
|
||||
pdf.ln(10)
|
||||
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data:
|
||||
self.logger(" -> Skip PDF Creation: No comments to process.")
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
for i, comment in enumerate(comments_data):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, user)
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
if i < len(comments_data) - 1:
|
||||
pdf.ln(5)
|
||||
pdf.cell(0, 0, '', border='T')
|
||||
pdf.ln(5)
|
||||
else:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(0, 7, cleaned_text)
|
||||
|
||||
pdf.output(final_save_path)
|
||||
# Font setup
|
||||
font_path = ""
|
||||
if self.project_root_dir:
|
||||
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
|
||||
# Add content specific fields for the generator
|
||||
if self.text_only_scope == 'comments':
|
||||
common_content_data['comments_list_for_pdf'] = comments_data
|
||||
else:
|
||||
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
|
||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||
|
||||
common_content_data['content_text_for_pdf'] = cleaned_text
|
||||
|
||||
# Call the centralized function
|
||||
success = create_individual_pdf(
|
||||
post_data=common_content_data,
|
||||
output_filename=final_save_path,
|
||||
font_path=font_path,
|
||||
add_info_page=self.add_info_in_pdf, # <--- NEW PARAMETER
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise Exception("PDF generation failed (check logs)")
|
||||
|
||||
# --- DOCX Generation ---
|
||||
elif file_extension == 'docx':
|
||||
if Document:
|
||||
self.logger(f" Converting to DOCX...")
|
||||
document = Document()
|
||||
# Add simple header info if desired, or keep pure text
|
||||
if self.add_info_in_pdf:
|
||||
document.add_heading(post_title, 0)
|
||||
document.add_paragraph(f"Date: {common_content_data['published']}")
|
||||
document.add_paragraph(f"Creator: {common_content_data['creator_name']}")
|
||||
document.add_paragraph(f"URL: {common_content_data['original_link']}")
|
||||
document.add_page_break()
|
||||
|
||||
document.add_paragraph(cleaned_text)
|
||||
document.save(final_save_path)
|
||||
else:
|
||||
@@ -1452,9 +1559,20 @@ class PostProcessorWorker:
|
||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||
|
||||
else: # TXT file
|
||||
# --- TXT Generation ---
|
||||
else:
|
||||
content_to_write = cleaned_text
|
||||
# Optional: Add simple text header if "Add Info" is checked
|
||||
if self.add_info_in_pdf:
|
||||
header = (f"Title: {post_title}\n"
|
||||
f"Date: {common_content_data['published']}\n"
|
||||
f"Creator: {common_content_data['creator_name']}\n"
|
||||
f"URL: {common_content_data['original_link']}\n"
|
||||
f"{'-'*40}\n\n")
|
||||
content_to_write = header + cleaned_text
|
||||
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(cleaned_text)
|
||||
f.write(content_to_write)
|
||||
|
||||
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
|
||||
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
|
||||
@@ -1467,6 +1585,7 @@ class PostProcessorWorker:
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
|
||||
|
||||
if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match:
|
||||
self.logger(f" -> Skip Post (Renaming Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.")
|
||||
self._emit_signal('missed_character_post', post_title, "Renaming Mode: No title match for character filter (Title/Both scope)")
|
||||
@@ -2212,9 +2331,10 @@ class DownloadThread(QThread):
|
||||
manga_custom_filename_format="{published} {title}",
|
||||
manga_custom_date_format="YYYY-MM-DD" ,
|
||||
sfp_threshold=None,
|
||||
creator_name_cache=None
|
||||
|
||||
creator_name_cache=None,
|
||||
proxies=None
|
||||
):
|
||||
|
||||
super().__init__()
|
||||
self.api_url_input = api_url_input
|
||||
self.output_dir = output_dir
|
||||
@@ -2289,6 +2409,7 @@ class DownloadThread(QThread):
|
||||
self.domain_override = domain_override
|
||||
self.sfp_threshold = sfp_threshold
|
||||
self.creator_name_cache = creator_name_cache
|
||||
self.proxies = proxies
|
||||
|
||||
if self.compress_images and Image is None:
|
||||
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
|
||||
@@ -2322,6 +2443,7 @@ class DownloadThread(QThread):
|
||||
|
||||
self.logger(" Starting post fetch (single-threaded download process)...")
|
||||
|
||||
# --- FIX: Removed duplicate proxies argument here ---
|
||||
post_generator = download_from_api(
|
||||
self.api_url_input,
|
||||
logger=self.logger,
|
||||
@@ -2336,7 +2458,8 @@ class DownloadThread(QThread):
|
||||
app_base_dir=self.app_base_dir,
|
||||
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
|
||||
processed_post_ids=self.processed_post_ids_set,
|
||||
fetch_all_first=self.fetch_first
|
||||
fetch_all_first=self.fetch_first,
|
||||
proxies=self.proxies
|
||||
)
|
||||
|
||||
for posts_batch_data in post_generator:
|
||||
@@ -2349,6 +2472,7 @@ class DownloadThread(QThread):
|
||||
was_process_cancelled = True
|
||||
break
|
||||
|
||||
# --- FIX: Ensure 'proxies' is in this dictionary ---
|
||||
worker_args = {
|
||||
'post_data': individual_post_data,
|
||||
'emitter': worker_signals_obj,
|
||||
@@ -2417,7 +2541,8 @@ class DownloadThread(QThread):
|
||||
'archive_only_mode': self.archive_only_mode,
|
||||
'manga_custom_filename_format': self.manga_custom_filename_format,
|
||||
'manga_custom_date_format': self.manga_custom_date_format,
|
||||
'sfp_threshold': self.sfp_threshold
|
||||
'sfp_threshold': self.sfp_threshold,
|
||||
'proxies': self.proxies
|
||||
}
|
||||
|
||||
post_processing_worker = PostProcessorWorker(**worker_args)
|
||||
|
||||
@@ -145,7 +145,9 @@ def download_and_decrypt_mega_file(info, download_path, logger_func, progress_ca
|
||||
logger_func(f" [Mega] Download for '{file_name}' cancelled before starting.")
|
||||
return
|
||||
|
||||
if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:
|
||||
|
||||
# i tried to make the mega download multipart for big file but it didnt work you can try if you can fix this to make it multipart replace "if true" with this "if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:" to activate multipart
|
||||
if True:
|
||||
logger_func(f" [Mega] Downloading '{file_name}' (Single Stream)...")
|
||||
try:
|
||||
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=0)
|
||||
|
||||
@@ -6,7 +6,7 @@ from packaging.version import parse as parse_version
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
|
||||
# Constants for the updater
|
||||
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi63771/Kemono-Downloader/releases/latest"
|
||||
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi9587/Kemono-Downloader/releases/latest"
|
||||
EXE_NAME = "Kemono.Downloader.exe"
|
||||
|
||||
class UpdateChecker(QThread):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
212
src/ui/classes/deviantart_downloader_thread.py
Normal file
212
src/ui/classes/deviantart_downloader_thread.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import re
|
||||
import random # Needed for random delays
|
||||
from datetime import datetime
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
from ...core.deviantart_client import DeviantArtClient
|
||||
from ...utils.file_utils import clean_folder_name
|
||||
|
||||
class DeviantArtDownloadThread(QThread):
|
||||
progress_signal = pyqtSignal(str)
|
||||
file_progress_signal = pyqtSignal(str, object)
|
||||
overall_progress_signal = pyqtSignal(int, int)
|
||||
finished_signal = pyqtSignal(int, int, bool, list)
|
||||
|
||||
# 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
|
||||
self.proxies = proxies # Store proxies
|
||||
|
||||
self.parent_app = parent
|
||||
self.download_count = 0
|
||||
self.skip_count = 0
|
||||
|
||||
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}")
|
||||
|
||||
try:
|
||||
if not self.client.authenticate():
|
||||
self.progress_signal.emit("❌ Failed to authenticate with DeviantArt API.")
|
||||
self.finished_signal.emit(0, 0, True, [])
|
||||
return
|
||||
|
||||
mode, username, _ = self.client.extract_info_from_url(self.url)
|
||||
|
||||
if mode == 'post':
|
||||
self._process_single_post(self.url)
|
||||
elif mode == 'gallery':
|
||||
self._process_gallery(username)
|
||||
else:
|
||||
self.progress_signal.emit("❌ Could not parse DeviantArt URL type.")
|
||||
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f"❌ Error during download: {e}")
|
||||
self.skip_count += 1
|
||||
finally:
|
||||
self.finished_signal.emit(self.download_count, self.skip_count, self.cancellation_event.is_set(), [])
|
||||
|
||||
def _check_pause_cancel(self):
|
||||
if self.cancellation_event.is_set(): return True
|
||||
while self.pause_event.is_set():
|
||||
time.sleep(0.5)
|
||||
if self.cancellation_event.is_set(): return True
|
||||
return False
|
||||
|
||||
def _process_single_post(self, url):
|
||||
self.progress_signal.emit(f" Fetching deviation info...")
|
||||
uuid = self.client.get_deviation_uuid(url)
|
||||
if not uuid:
|
||||
self.progress_signal.emit("❌ Could not find Deviation UUID.")
|
||||
self.skip_count += 1
|
||||
return
|
||||
|
||||
meta = self.client._api_call(f"/deviation/{uuid}")
|
||||
content = self.client.get_deviation_content(uuid)
|
||||
if not content:
|
||||
self.progress_signal.emit("❌ Could not retrieve download URL.")
|
||||
self.skip_count += 1
|
||||
return
|
||||
|
||||
self._download_file(content['src'], meta)
|
||||
|
||||
def _process_gallery(self, username):
|
||||
self.progress_signal.emit(f" Fetching gallery for user: {username}...")
|
||||
offset = 0
|
||||
has_more = True
|
||||
|
||||
base_folder = os.path.join(self.output_dir, clean_folder_name(username))
|
||||
if not os.path.exists(base_folder):
|
||||
os.makedirs(base_folder, exist_ok=True)
|
||||
|
||||
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
|
||||
self._process_deviation_task(deviation, base_folder)
|
||||
|
||||
# 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
|
||||
|
||||
dev_id = deviation.get('deviationid')
|
||||
title = deviation.get('title', 'Unknown')
|
||||
|
||||
try:
|
||||
content = self.client.get_deviation_content(dev_id)
|
||||
if content:
|
||||
self._download_file(content['src'], deviation, override_dir=base_folder)
|
||||
else:
|
||||
self.skip_count += 1
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" ❌ Error processing {title}: {e}")
|
||||
self.skip_count += 1
|
||||
|
||||
def _format_date(self, timestamp):
|
||||
if not timestamp: return "NoDate"
|
||||
try:
|
||||
fmt_setting = self.parent_app.manga_custom_date_format
|
||||
strftime_fmt = fmt_setting.replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
|
||||
dt_obj = datetime.fromtimestamp(int(timestamp))
|
||||
return dt_obj.strftime(strftime_fmt)
|
||||
except Exception:
|
||||
return "InvalidDate"
|
||||
|
||||
def _download_file(self, file_url, metadata, override_dir=None):
|
||||
if self._check_pause_cancel(): return
|
||||
|
||||
parsed = requests.utils.urlparse(file_url)
|
||||
path_filename = os.path.basename(parsed.path)
|
||||
if '?' in path_filename: path_filename = path_filename.split('?')[0]
|
||||
_, ext = os.path.splitext(path_filename)
|
||||
|
||||
title = metadata.get('title', 'Untitled')
|
||||
safe_title = clean_folder_name(title)
|
||||
if not safe_title: safe_title = "Untitled"
|
||||
|
||||
final_filename = f"{safe_title}{ext}"
|
||||
|
||||
if self.parent_app and self.parent_app.manga_mode_checkbox.isChecked():
|
||||
try:
|
||||
creator_name = metadata.get('author', {}).get('username', 'Unknown')
|
||||
published_ts = metadata.get('published_time')
|
||||
|
||||
fmt_data = {
|
||||
"creator_name": creator_name,
|
||||
"title": title,
|
||||
"published": self._format_date(published_ts),
|
||||
"added": self._format_date(published_ts),
|
||||
"edited": self._format_date(published_ts),
|
||||
"id": metadata.get('deviationid', ''),
|
||||
"service": "deviantart",
|
||||
"name": safe_title
|
||||
}
|
||||
|
||||
custom_fmt = self.parent_app.custom_manga_filename_format
|
||||
new_name = custom_fmt.format(**fmt_data)
|
||||
final_filename = f"{clean_folder_name(new_name)}{ext}"
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
save_dir = override_dir if override_dir else self.output_dir
|
||||
if not os.path.exists(save_dir):
|
||||
try:
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
except OSError: pass
|
||||
|
||||
filepath = os.path.join(save_dir, final_filename)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
return
|
||||
|
||||
try:
|
||||
self.progress_signal.emit(f" ⬇️ Downloading: {final_filename}")
|
||||
|
||||
# 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:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if self._check_pause_cancel():
|
||||
f.close()
|
||||
os.remove(filepath)
|
||||
return
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
self.download_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" ❌ Download failed: {e}")
|
||||
self.skip_count += 1
|
||||
@@ -24,7 +24,7 @@ from .rule34video_downloader_thread import Rule34VideoDownloadThread
|
||||
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
|
||||
|
||||
def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
|
||||
"""
|
||||
@@ -175,6 +175,17 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
|
||||
# id1 contains the full URL or album ID from extract_post_info
|
||||
return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for DeviantArt
|
||||
if service == 'deviantart':
|
||||
main_app.log_signal.emit(f"ℹ️ DeviantArt URL detected. Starting dedicated downloader.")
|
||||
return DeviantArtDownloadThread(
|
||||
url=api_url,
|
||||
output_dir=effective_output_dir_for_run,
|
||||
pause_event=main_app.pause_event,
|
||||
cancellation_event=main_app.cancellation_event,
|
||||
parent=main_app
|
||||
)
|
||||
# ----------------------
|
||||
# --- Fallback ---
|
||||
# If no specific handler matched based on service name or URL pattern, return None.
|
||||
# This signals main_window.py to use the generic BackendDownloadThread/PostProcessorWorker
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -254,6 +254,7 @@ class SimpCityDownloadThread(QThread):
|
||||
self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
|
||||
self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
|
||||
self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
|
||||
self.should_dl_images = self.parent_app.simpcity_dl_images_cb.isChecked()
|
||||
self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
|
||||
self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
|
||||
|
||||
@@ -288,8 +289,10 @@ class SimpCityDownloadThread(QThread):
|
||||
enriched_jobs = self._get_enriched_jobs(jobs)
|
||||
if enriched_jobs:
|
||||
for job in enriched_jobs:
|
||||
if job['type'] == 'image': self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
if job['type'] == 'image':
|
||||
if self.should_dl_images: self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
|
||||
else:
|
||||
base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
|
||||
page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
|
||||
@@ -347,11 +350,14 @@ class SimpCityDownloadThread(QThread):
|
||||
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
|
||||
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
else:
|
||||
for job in enriched_jobs:
|
||||
self.processed_job_urls.add(job.get('url'))
|
||||
if job['type'] == 'image': self.image_queue.put(job)
|
||||
if job['type'] == 'image':
|
||||
if self.should_dl_images: self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
|
||||
page_fetch_successful = True; break
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code in [403, 404]:
|
||||
|
||||
@@ -7,7 +7,6 @@ from PyQt5.QtCore import Qt
|
||||
class CustomFilenameDialog(QDialog):
|
||||
"""A dialog for creating a custom filename format string."""
|
||||
|
||||
# --- REPLACE THE 'AVAILABLE_KEYS' LIST WITH THIS DICTIONARY ---
|
||||
DISPLAY_KEY_MAP = {
|
||||
"PostID": "id",
|
||||
"CreatorName": "creator_name",
|
||||
@@ -19,7 +18,10 @@ class CustomFilenameDialog(QDialog):
|
||||
"name": "name"
|
||||
}
|
||||
|
||||
def __init__(self, current_format, current_date_format, parent=None):
|
||||
# STRICT LIST: Only these three will be clickable for DeviantArt
|
||||
DA_ALLOWED_KEYS = ["creator_name", "title", "published"]
|
||||
|
||||
def __init__(self, current_format, current_date_format, parent=None, is_deviantart=False):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Custom Filename Format")
|
||||
self.setMinimumWidth(500)
|
||||
@@ -31,9 +33,11 @@ class CustomFilenameDialog(QDialog):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# --- Description ---
|
||||
description_label = QLabel(
|
||||
"Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format."
|
||||
)
|
||||
desc_text = "Create a filename format using placeholders. The date/time values will be automatically formatted."
|
||||
if is_deviantart:
|
||||
desc_text += "\n\n(DeviantArt Mode: Only Creator Name, Title, and Upload Date are available. Other buttons are disabled.)"
|
||||
|
||||
description_label = QLabel(desc_text)
|
||||
description_label.setWordWrap(True)
|
||||
layout.addWidget(description_label)
|
||||
|
||||
@@ -42,15 +46,20 @@ class CustomFilenameDialog(QDialog):
|
||||
layout.addWidget(format_label)
|
||||
self.format_input = QLineEdit(self)
|
||||
self.format_input.setText(self.current_format)
|
||||
self.format_input.setPlaceholderText("e.g., {published} {title} {id}")
|
||||
|
||||
if is_deviantart:
|
||||
self.format_input.setPlaceholderText("e.g., {published} {title} {creator_name}")
|
||||
else:
|
||||
self.format_input.setPlaceholderText("e.g., {published} {title} {id}")
|
||||
|
||||
layout.addWidget(self.format_input)
|
||||
|
||||
# --- Date Format Input ---
|
||||
date_format_label = QLabel("Date Format (for {added}, {published}, {edited}):")
|
||||
date_format_label = QLabel("Date Format (for {published}):")
|
||||
layout.addWidget(date_format_label)
|
||||
self.date_format_input = QLineEdit(self)
|
||||
self.date_format_input.setText(self.current_date_format)
|
||||
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD or DD-MM-YYYY")
|
||||
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD")
|
||||
layout.addWidget(self.date_format_input)
|
||||
|
||||
# --- Available Keys Display ---
|
||||
@@ -62,7 +71,20 @@ class CustomFilenameDialog(QDialog):
|
||||
|
||||
for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
|
||||
key_button = QPushButton(f"{{{display_key}}}")
|
||||
# Use a lambda to pass the correct internal key when the button is clicked
|
||||
|
||||
# --- DeviantArt Logic ---
|
||||
if is_deviantart:
|
||||
if internal_key in self.DA_ALLOWED_KEYS:
|
||||
# Active buttons: Bold text, enabled
|
||||
key_button.setStyleSheet("font-weight: bold; color: black;")
|
||||
key_button.setEnabled(True)
|
||||
else:
|
||||
# Inactive buttons: Disabled (Cannot be clicked)
|
||||
key_button.setEnabled(False)
|
||||
key_button.setToolTip("Not available for DeviantArt")
|
||||
# ------------------------
|
||||
|
||||
# Use a lambda to pass the correct internal key when clicked
|
||||
key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
|
||||
keys_layout.addWidget(key_button)
|
||||
keys_layout.addStretch()
|
||||
@@ -81,9 +103,7 @@ class CustomFilenameDialog(QDialog):
|
||||
self.format_input.setFocus()
|
||||
|
||||
def get_format_string(self):
|
||||
"""Returns the final format string from the input field."""
|
||||
return self.format_input.text().strip()
|
||||
|
||||
def get_date_format_string(self):
|
||||
"""Returns the date format string from its input field."""
|
||||
return self.date_format_input.text().strip()
|
||||
return self.date_format_input.text().strip()
|
||||
@@ -156,6 +156,9 @@ class EmptyPopupDialog (QDialog ):
|
||||
|
||||
# --- MODIFIED: Store a list of profiles now ---
|
||||
self.update_profiles_list = None
|
||||
# --- NEW: Flag to indicate if settings should load to UI ---
|
||||
self.load_settings_into_ui_requested = False
|
||||
|
||||
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
|
||||
self.update_profile_data = None
|
||||
self.update_creator_name = None
|
||||
@@ -341,6 +344,9 @@ class EmptyPopupDialog (QDialog ):
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
# --- MODIFIED: Get a list of profiles now ---
|
||||
selected_profiles = dialog.get_selected_profiles()
|
||||
# --- NEW: Get the checkbox state ---
|
||||
self.load_settings_into_ui_requested = dialog.should_load_into_ui()
|
||||
|
||||
if selected_profiles:
|
||||
try:
|
||||
# --- MODIFIED: Store the list ---
|
||||
@@ -1052,4 +1058,4 @@ class EmptyPopupDialog (QDialog ):
|
||||
else :
|
||||
if unique_key in self .globally_selected_creators :
|
||||
del self .globally_selected_creators [unique_key ]
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
|
||||
@@ -5,9 +5,11 @@ import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
||||
from PyQt5.QtGui import QIntValidator # <--- NEW: Added for Port validation
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
|
||||
QTabWidget, QWidget, QFileDialog
|
||||
)
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
@@ -20,7 +22,9 @@ from ...config.constants import (
|
||||
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
|
||||
DATE_PREFIX_FORMAT_KEY,
|
||||
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
|
||||
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY
|
||||
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY,
|
||||
PROXY_ENABLED_KEY, PROXY_HOST_KEY, PROXY_PORT_KEY,
|
||||
PROXY_USERNAME_KEY, PROXY_PASSWORD_KEY
|
||||
)
|
||||
from ...services.updater import UpdateChecker, UpdateDownloader
|
||||
|
||||
@@ -111,21 +115,21 @@ class CountdownMessageBox(QDialog):
|
||||
class FutureSettingsDialog(QDialog):
|
||||
"""
|
||||
A dialog for managing application-wide settings like theme, language,
|
||||
and display options, with an organized layout.
|
||||
and display options, using a tabbed layout.
|
||||
"""
|
||||
def __init__(self, parent_app_ref, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent_app_ref
|
||||
self.setModal(True)
|
||||
self.update_downloader_thread = None # To keep a reference
|
||||
self.update_downloader_thread = None
|
||||
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||
scale_factor = screen_height / 800.0
|
||||
base_min_w, base_min_h = 420, 520 # Increased height for new options
|
||||
scale_factor = screen_height / 1000.0
|
||||
base_min_w, base_min_h = 550, 450 # <--- TWEAK: Slightly increased width for better layout
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
@@ -133,71 +137,167 @@ class FutureSettingsDialog(QDialog):
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
self._apply_theme()
|
||||
|
||||
# <--- NEW: Load proxy settings on init
|
||||
self._load_proxy_settings()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initializes all UI components and layouts for the dialog."""
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# --- Create Tab Widget ---
|
||||
self.tab_widget = QTabWidget()
|
||||
main_layout.addWidget(self.tab_widget)
|
||||
|
||||
self.interface_group_box = QGroupBox()
|
||||
interface_layout = QGridLayout(self.interface_group_box)
|
||||
# --- Create Tabs ---
|
||||
self.display_tab = QWidget()
|
||||
self.downloads_tab = QWidget()
|
||||
self.network_tab = QWidget() # <--- NEW: Network Tab
|
||||
self.updates_tab = QWidget()
|
||||
|
||||
# Add tabs to the widget
|
||||
self.tab_widget.addTab(self.display_tab, "Display")
|
||||
self.tab_widget.addTab(self.downloads_tab, "Downloads")
|
||||
self.tab_widget.addTab(self.network_tab, "Proxy/Network") # <--- NEW
|
||||
self.tab_widget.addTab(self.updates_tab, "Updates")
|
||||
|
||||
# [Display Tab Code (Unchanged) ...]
|
||||
display_tab_layout = QVBoxLayout(self.display_tab)
|
||||
self.display_group_box = QGroupBox()
|
||||
display_layout = QGridLayout(self.display_group_box)
|
||||
|
||||
self.theme_label = QLabel()
|
||||
self.theme_toggle_button = QPushButton()
|
||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||
interface_layout.addWidget(self.theme_label, 0, 0)
|
||||
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
display_layout.addWidget(self.theme_label, 0, 0)
|
||||
display_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
|
||||
self.ui_scale_label = QLabel()
|
||||
self.ui_scale_combo_box = QComboBox()
|
||||
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
interface_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
display_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
display_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
|
||||
self.language_label = QLabel()
|
||||
self.language_combo_box = QComboBox()
|
||||
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
|
||||
interface_layout.addWidget(self.language_label, 2, 0)
|
||||
interface_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
display_layout.addWidget(self.language_label, 2, 0)
|
||||
display_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
|
||||
main_layout.addWidget(self.interface_group_box)
|
||||
|
||||
self.download_window_group_box = QGroupBox()
|
||||
download_window_layout = QGridLayout(self.download_window_group_box)
|
||||
|
||||
self.window_size_label = QLabel()
|
||||
self.resolution_combo_box = QComboBox()
|
||||
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
download_window_layout.addWidget(self.window_size_label, 0, 0)
|
||||
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
|
||||
display_layout.addWidget(self.window_size_label, 3, 0)
|
||||
display_layout.addWidget(self.resolution_combo_box, 3, 1)
|
||||
|
||||
display_tab_layout.addWidget(self.display_group_box)
|
||||
display_tab_layout.addStretch(1)
|
||||
|
||||
# [Downloads Tab Code (Unchanged) ...]
|
||||
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
|
||||
self.download_settings_group_box = QGroupBox()
|
||||
download_settings_layout = QGridLayout(self.download_settings_group_box)
|
||||
|
||||
self.default_path_label = QLabel()
|
||||
self.save_path_button = QPushButton()
|
||||
self.save_path_button.clicked.connect(self._save_settings)
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
self.date_prefix_format_label = QLabel()
|
||||
self.date_prefix_format_input = QLineEdit()
|
||||
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
|
||||
download_window_layout.addWidget(self.date_prefix_format_label, 2, 0)
|
||||
download_window_layout.addWidget(self.date_prefix_format_input, 2, 1)
|
||||
download_settings_layout.addWidget(self.default_path_label, 0, 0)
|
||||
download_settings_layout.addWidget(self.save_path_button, 0, 1)
|
||||
|
||||
self.post_download_action_label = QLabel()
|
||||
self.post_download_action_combo = QComboBox()
|
||||
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
|
||||
download_window_layout.addWidget(self.post_download_action_label, 3, 0)
|
||||
download_window_layout.addWidget(self.post_download_action_combo, 3, 1)
|
||||
download_settings_layout.addWidget(self.post_download_action_label, 1, 0)
|
||||
download_settings_layout.addWidget(self.post_download_action_combo, 1, 1)
|
||||
|
||||
self.date_prefix_format_label = QLabel()
|
||||
self.date_prefix_format_input = QLineEdit()
|
||||
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
|
||||
download_settings_layout.addWidget(self.date_prefix_format_label, 2, 0)
|
||||
download_settings_layout.addWidget(self.date_prefix_format_input, 2, 1)
|
||||
|
||||
self.save_creator_json_checkbox = QCheckBox()
|
||||
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
|
||||
download_window_layout.addWidget(self.save_creator_json_checkbox, 4, 0, 1, 2)
|
||||
download_settings_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2)
|
||||
|
||||
self.fetch_first_checkbox = QCheckBox()
|
||||
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
||||
download_window_layout.addWidget(self.fetch_first_checkbox, 5, 0, 1, 2)
|
||||
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
|
||||
|
||||
main_layout.addWidget(self.download_window_group_box)
|
||||
settings_file_layout = QHBoxLayout()
|
||||
self.load_settings_button = QPushButton()
|
||||
self.save_settings_button = QPushButton()
|
||||
settings_file_layout.addWidget(self.load_settings_button)
|
||||
settings_file_layout.addWidget(self.save_settings_button)
|
||||
settings_file_layout.addStretch(1)
|
||||
|
||||
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2)
|
||||
|
||||
self.load_settings_button.clicked.connect(self._handle_load_settings)
|
||||
self.save_settings_button.clicked.connect(self._handle_save_settings)
|
||||
|
||||
downloads_tab_layout.addWidget(self.download_settings_group_box)
|
||||
downloads_tab_layout.addStretch(1)
|
||||
|
||||
# --- START: Network Tab (NEW) ---
|
||||
network_tab_layout = QVBoxLayout(self.network_tab)
|
||||
self.proxy_group_box = QGroupBox()
|
||||
proxy_layout = QGridLayout(self.proxy_group_box)
|
||||
|
||||
# Enable Checkbox
|
||||
self.proxy_enabled_checkbox = QCheckBox()
|
||||
self.proxy_enabled_checkbox.stateChanged.connect(self._proxy_setting_changed)
|
||||
proxy_layout.addWidget(self.proxy_enabled_checkbox, 0, 0, 1, 2)
|
||||
|
||||
# Proxy Type Dropdown
|
||||
self.proxy_type_label = QLabel("Proxy Type:")
|
||||
self.proxy_type_combo = QComboBox()
|
||||
self.proxy_type_combo.addItems(["HTTP", "SOCKS4", "SOCKS5"])
|
||||
self.proxy_type_combo.currentIndexChanged.connect(self._proxy_setting_changed)
|
||||
proxy_layout.addWidget(self.proxy_type_label, 1, 0)
|
||||
proxy_layout.addWidget(self.proxy_type_combo, 1, 1)
|
||||
|
||||
|
||||
# Host / IP
|
||||
self.proxy_host_label = QLabel()
|
||||
self.proxy_host_input = QLineEdit()
|
||||
self.proxy_host_input.setPlaceholderText("127.0.0.1")
|
||||
self.proxy_host_input.editingFinished.connect(self._proxy_setting_changed)
|
||||
proxy_layout.addWidget(self.proxy_host_label, 2, 0) # Changed row to 2
|
||||
proxy_layout.addWidget(self.proxy_host_input, 2, 1)
|
||||
|
||||
# Port
|
||||
self.proxy_port_label = QLabel()
|
||||
self.proxy_port_input = QLineEdit()
|
||||
self.proxy_port_input.setPlaceholderText("8080")
|
||||
self.proxy_port_input.setValidator(QIntValidator(1, 65535, self)) # Only numbers
|
||||
self.proxy_port_input.editingFinished.connect(self._proxy_setting_changed)
|
||||
proxy_layout.addWidget(self.proxy_port_label, 3, 0)
|
||||
proxy_layout.addWidget(self.proxy_port_input, 3, 1)
|
||||
|
||||
# Username
|
||||
self.proxy_user_label = QLabel()
|
||||
self.proxy_user_input = QLineEdit()
|
||||
self.proxy_user_input.setPlaceholderText("(Optional)")
|
||||
self.proxy_user_input.editingFinished.connect(self._proxy_setting_changed)
|
||||
proxy_layout.addWidget(self.proxy_user_label, 4, 0)
|
||||
proxy_layout.addWidget(self.proxy_user_input, 4, 1)
|
||||
|
||||
# Password
|
||||
self.proxy_pass_label = QLabel()
|
||||
self.proxy_pass_input = QLineEdit()
|
||||
self.proxy_pass_input.setPlaceholderText("(Optional)")
|
||||
self.proxy_pass_input.setEchoMode(QLineEdit.Password) # Mask input
|
||||
self.proxy_pass_input.editingFinished.connect(self._proxy_setting_changed)
|
||||
proxy_layout.addWidget(self.proxy_pass_label, 5, 0)
|
||||
proxy_layout.addWidget(self.proxy_pass_input, 5, 1)
|
||||
|
||||
network_tab_layout.addWidget(self.proxy_group_box)
|
||||
network_tab_layout.addStretch(1)
|
||||
# --- END: Network Tab (NEW) ---
|
||||
|
||||
# [Updates Tab Code (Unchanged) ...]
|
||||
updates_tab_layout = QVBoxLayout(self.updates_tab)
|
||||
self.update_group_box = QGroupBox()
|
||||
update_layout = QGridLayout(self.update_group_box)
|
||||
self.version_label = QLabel()
|
||||
@@ -207,29 +307,40 @@ class FutureSettingsDialog(QDialog):
|
||||
update_layout.addWidget(self.version_label, 0, 0)
|
||||
update_layout.addWidget(self.update_status_label, 0, 1)
|
||||
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
||||
main_layout.addWidget(self.update_group_box)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
|
||||
updates_tab_layout.addWidget(self.update_group_box)
|
||||
updates_tab_layout.addStretch(1)
|
||||
|
||||
# --- OK Button (outside tabs) ---
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch(1)
|
||||
self.ok_button = QPushButton()
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
|
||||
def _retranslate_ui(self):
|
||||
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
|
||||
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
|
||||
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
|
||||
|
||||
# --- Tab Titles ---
|
||||
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display"))
|
||||
self.tab_widget.setTabText(1, self._tr("settings_tab_downloads", "Downloads"))
|
||||
self.tab_widget.setTabText(2, self._tr("settings_tab_network", "Proxy/Network")) # <--- NEW
|
||||
self.tab_widget.setTabText(3, self._tr("settings_tab_updates", "Updates"))
|
||||
|
||||
# [Display Tab (Unchanged) ...]
|
||||
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
|
||||
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
||||
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
|
||||
# [Downloads Tab (Unchanged) ...]
|
||||
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
|
||||
# Update placeholder to include {post}
|
||||
self.date_prefix_format_input.setPlaceholderText(self._tr("date_prefix_format_placeholder", "e.g., YYYY-MM-DD {post} {postid}"))
|
||||
# Add the tooltip to explain usage
|
||||
self.date_prefix_format_input.setToolTip(self._tr(
|
||||
"date_prefix_format_tooltip",
|
||||
"Create a custom folder name using placeholders:\n"
|
||||
@@ -238,24 +349,35 @@ class FutureSettingsDialog(QDialog):
|
||||
"• {postid}: for the post's unique ID\n\n"
|
||||
"Example: {post} [{postid}] [YYYY-MM-DD]"
|
||||
))
|
||||
|
||||
|
||||
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||
|
||||
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
||||
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
|
||||
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._update_theme_toggle_button_text()
|
||||
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.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
self.load_settings_button.setText(self._tr("load_settings_button", "Load Settings..."))
|
||||
self.load_settings_button.setToolTip(self._tr("load_settings_tooltip", "Load all download settings from a .json file."))
|
||||
self.save_settings_button.setText(self._tr("save_settings_button", "Save Settings..."))
|
||||
self.save_settings_button.setToolTip(self._tr("save_settings_tooltip", "Save all current download settings to a .json file."))
|
||||
|
||||
# --- START: Network Tab (NEW) ---
|
||||
self.proxy_group_box.setTitle(self._tr("proxy_settings_group_title", "Proxy Configuration"))
|
||||
self.proxy_enabled_checkbox.setText(self._tr("proxy_enabled_label", "Enable Proxy"))
|
||||
self.proxy_host_label.setText(self._tr("proxy_host_label", "Host / IP:"))
|
||||
self.proxy_port_label.setText(self._tr("proxy_port_label", "Port:"))
|
||||
self.proxy_user_label.setText(self._tr("proxy_user_label", "Username (Optional):"))
|
||||
self.proxy_pass_label.setText(self._tr("proxy_pass_label", "Password (Optional):"))
|
||||
# --- END: Network Tab (NEW) ---
|
||||
|
||||
# [Updates Tab (Unchanged) ...]
|
||||
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
||||
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
||||
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
|
||||
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
|
||||
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
||||
|
||||
self._update_theme_toggle_button_text()
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
@@ -263,6 +385,82 @@ class FutureSettingsDialog(QDialog):
|
||||
self._load_date_prefix_format()
|
||||
self._load_checkbox_states()
|
||||
|
||||
# --- START: New Proxy Logic ---
|
||||
def _load_proxy_settings(self):
|
||||
"""Loads proxy settings from QSettings into the UI."""
|
||||
# Block signals to prevent triggering auto-save while loading
|
||||
self.proxy_enabled_checkbox.blockSignals(True)
|
||||
self.proxy_type_combo.blockSignals(True) # <--- NEW
|
||||
self.proxy_host_input.blockSignals(True)
|
||||
self.proxy_port_input.blockSignals(True)
|
||||
self.proxy_user_input.blockSignals(True)
|
||||
self.proxy_pass_input.blockSignals(True)
|
||||
|
||||
# Load values
|
||||
enabled = self.parent_app.settings.value(PROXY_ENABLED_KEY, False, type=bool)
|
||||
proxy_type = self.parent_app.settings.value("proxy_type", "HTTP", type=str) # <--- NEW
|
||||
host = self.parent_app.settings.value(PROXY_HOST_KEY, "", type=str)
|
||||
port = self.parent_app.settings.value(PROXY_PORT_KEY, "", type=str)
|
||||
user = self.parent_app.settings.value(PROXY_USERNAME_KEY, "", type=str)
|
||||
password = self.parent_app.settings.value(PROXY_PASSWORD_KEY, "", type=str)
|
||||
|
||||
# Apply values to UI
|
||||
self.proxy_enabled_checkbox.setChecked(enabled)
|
||||
|
||||
# <--- NEW: Set the dropdown selection
|
||||
index = self.proxy_type_combo.findText(proxy_type)
|
||||
if index >= 0:
|
||||
self.proxy_type_combo.setCurrentIndex(index)
|
||||
else:
|
||||
self.proxy_type_combo.setCurrentIndex(0) # Default to first item if not found
|
||||
|
||||
self.proxy_host_input.setText(host)
|
||||
self.proxy_port_input.setText(port)
|
||||
self.proxy_user_input.setText(user)
|
||||
self.proxy_pass_input.setText(password)
|
||||
|
||||
self._update_proxy_fields_state(enabled)
|
||||
|
||||
# Unblock signals
|
||||
self.proxy_enabled_checkbox.blockSignals(False)
|
||||
self.proxy_type_combo.blockSignals(False) # <--- NEW
|
||||
self.proxy_host_input.blockSignals(False)
|
||||
self.proxy_port_input.blockSignals(False)
|
||||
self.proxy_user_input.blockSignals(False)
|
||||
self.proxy_pass_input.blockSignals(False)
|
||||
|
||||
def _proxy_setting_changed(self):
|
||||
"""Saves the current proxy UI state to QSettings."""
|
||||
enabled = self.proxy_enabled_checkbox.isChecked()
|
||||
proxy_type = self.proxy_type_combo.currentText() # <--- NEW
|
||||
host = self.proxy_host_input.text().strip()
|
||||
port = self.proxy_port_input.text().strip()
|
||||
user = self.proxy_user_input.text().strip()
|
||||
password = self.proxy_pass_input.text().strip()
|
||||
|
||||
self.parent_app.settings.setValue(PROXY_ENABLED_KEY, enabled)
|
||||
self.parent_app.settings.setValue("proxy_type", proxy_type) # <--- NEW
|
||||
self.parent_app.settings.setValue(PROXY_HOST_KEY, host)
|
||||
self.parent_app.settings.setValue(PROXY_PORT_KEY, port)
|
||||
self.parent_app.settings.setValue(PROXY_USERNAME_KEY, user)
|
||||
self.parent_app.settings.setValue(PROXY_PASSWORD_KEY, password)
|
||||
|
||||
self.parent_app.settings.sync()
|
||||
|
||||
self._update_proxy_fields_state(enabled)
|
||||
|
||||
# Optional: Notify main app that network settings changed if needed
|
||||
# self.parent_app.reload_proxy_settings()
|
||||
|
||||
def _update_proxy_fields_state(self, enabled):
|
||||
"""Enables or disables input fields based on the checkbox."""
|
||||
self.proxy_type_combo.setEnabled(enabled)
|
||||
self.proxy_host_input.setEnabled(enabled)
|
||||
self.proxy_port_input.setEnabled(enabled)
|
||||
self.proxy_user_input.setEnabled(enabled)
|
||||
self.proxy_pass_input.setEnabled(enabled)
|
||||
# --- END: New Proxy Logic ---
|
||||
|
||||
def _check_for_updates(self):
|
||||
self.check_update_button.setEnabled(False)
|
||||
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
|
||||
@@ -331,7 +529,38 @@ class FutureSettingsDialog(QDialog):
|
||||
def _apply_theme(self):
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
base_stylesheet = get_dark_theme(scale)
|
||||
|
||||
# --- START: Tab Styling Fix ---
|
||||
tab_stylesheet = """
|
||||
QTabWidget::pane {
|
||||
border-top: 1px solid #444;
|
||||
margin-top: -1px; /* Overlap with tab bar */
|
||||
background-color: #2D2D2D;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background-color: #3D3D3D;
|
||||
color: #BBBBBB;
|
||||
border: 1px solid #444;
|
||||
border-bottom: none; /* No bottom border for tabs */
|
||||
padding: 6px 12px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #2D2D2D; /* Same as pane background */
|
||||
color: #EEEEEE;
|
||||
border-bottom: 1px solid #2D2D2D; /* Hides the pane top border */
|
||||
margin-bottom: -1px; /* Pulls tab down to cover pane border */
|
||||
}
|
||||
QTabBar::tab:!selected:hover {
|
||||
background-color: #4A4A4A;
|
||||
}
|
||||
"""
|
||||
# --- END: Tab Styling Fix ---
|
||||
|
||||
self.setStyleSheet(base_stylesheet + tab_stylesheet)
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
|
||||
@@ -490,4 +719,98 @@ class FutureSettingsDialog(QDialog):
|
||||
if path_saved or cookie_saved or token_saved:
|
||||
QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
|
||||
else:
|
||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||
|
||||
# --- START: New functions for Save/Load ---
|
||||
def _get_settings_dir(self):
|
||||
"""Helper to get a consistent directory for saving/loading profiles."""
|
||||
if hasattr(self.parent_app, 'user_data_path'):
|
||||
# We use 'user_data_path' which should point to 'appdata'
|
||||
settings_dir = os.path.join(self.parent_app.user_data_path, "settings_profiles")
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
return settings_dir
|
||||
# Fallback if user_data_path isn't available
|
||||
return QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation)
|
||||
|
||||
def _handle_save_settings(self):
|
||||
"""
|
||||
Calls the main app to get all settings, then saves them to a user-chosen JSON file.
|
||||
"""
|
||||
if not hasattr(self.parent_app, '_get_current_ui_settings_as_dict'):
|
||||
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
|
||||
self._tr("settings_missing_save_func_error", "Parent application is missing the required save function."))
|
||||
return
|
||||
|
||||
settings_dir = self._get_settings_dir()
|
||||
filepath, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
self._tr("save_settings_dialog_title", "Save Settings Profile"),
|
||||
settings_dir,
|
||||
self._tr("json_files_filter", "JSON Files (*.json)")
|
||||
)
|
||||
|
||||
if filepath:
|
||||
if not filepath.endswith('.json'):
|
||||
filepath += '.json'
|
||||
|
||||
try:
|
||||
# Get all settings from the main window
|
||||
settings_data = self.parent_app._get_current_ui_settings_as_dict()
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings_data, f, indent=2)
|
||||
|
||||
QMessageBox.information(self,
|
||||
self._tr("save_settings_success_title", "Settings Saved"),
|
||||
self._tr("save_settings_success_msg", "Settings successfully saved to:\n{filename}")
|
||||
.format(filename=os.path.basename(filepath)))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,
|
||||
self._tr("save_settings_error_title", "Error Saving Settings"),
|
||||
str(e))
|
||||
|
||||
def _handle_load_settings(self):
|
||||
"""
|
||||
Lets the user pick a JSON file, loads it, and applies the settings to the main app.
|
||||
"""
|
||||
if not hasattr(self.parent_app, '_load_ui_from_settings_dict') or \
|
||||
not hasattr(self.parent_app, '_update_all_ui_states'):
|
||||
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
|
||||
self._tr("settings_missing_load_func_error", "Parent application is missing the required load functions."))
|
||||
return
|
||||
|
||||
settings_dir = self._get_settings_dir()
|
||||
filepath, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
self._tr("load_settings_dialog_title", "Load Settings Profile"),
|
||||
settings_dir,
|
||||
self._tr("json_files_filter", "JSON Files (*.json)")
|
||||
)
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
settings_data = json.load(f)
|
||||
|
||||
if not isinstance(settings_data, dict):
|
||||
raise ValueError(self._tr("settings_invalid_json_error", "File is not a valid settings dictionary."))
|
||||
|
||||
# Apply all settings to the main window
|
||||
self.parent_app._load_ui_from_settings_dict(settings_data)
|
||||
|
||||
# Refresh the main window UI to show changes
|
||||
self.parent_app._update_all_ui_states()
|
||||
|
||||
QMessageBox.information(self,
|
||||
self._tr("load_settings_success_title", "Settings Loaded"),
|
||||
self._tr("load_settings_success_msg", "Successfully loaded settings from:\n{filename}")
|
||||
.format(filename=os.path.basename(filepath)))
|
||||
|
||||
# Close the settings dialog after loading
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,
|
||||
self._tr("load_settings_error_title", "Error Loading Settings"),
|
||||
str(e))
|
||||
# --- END: New functions for Save/Load ---
|
||||
@@ -6,7 +6,6 @@ from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
|
||||
)
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -26,7 +25,8 @@ class TourStepWidget(QWidget):
|
||||
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
# Use a consistent color for titles regardless of theme
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB; padding-bottom: 15px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
@@ -41,17 +41,505 @@ class TourStepWidget(QWidget):
|
||||
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
content_label.setTextFormat(Qt.RichText)
|
||||
content_label.setOpenExternalLinks(True)
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
|
||||
# Set a base line-height and color
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.5;")
|
||||
scroll_area.setWidget(content_label)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
|
||||
class HelpGuideDialog(QDialog):
|
||||
"""A multi-page dialog for displaying the feature guide with a navigation list."""
|
||||
|
||||
def __init__(self, steps_data, parent_app, parent=None):
|
||||
super().__init__(parent)
|
||||
self.steps_data = steps_data
|
||||
self.parent_app = parent_app
|
||||
super().__init__(parent_app)
|
||||
|
||||
self.parent_app = parent_app # This is the main_window instance
|
||||
|
||||
|
||||
self.steps_data = [
|
||||
("Welcome!",
|
||||
"""
|
||||
<p style='font-size: 12pt;'>Welcome to the Kemono Downloader! This guide will walk you through the key features to get you started.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Wide Range of Support</h3>
|
||||
<p>This application provides full, direct download support for several popular sites, including:</p>
|
||||
<ul>
|
||||
<li>Kemono</li>
|
||||
<li>Coomer</li>
|
||||
<li>Bunkr</li>
|
||||
<li>Erome</li>
|
||||
<li>Saint2.su</li>
|
||||
<li>nhentai.net/</li>
|
||||
<li>fap-nation.org/</li>
|
||||
<li>Discord</li>
|
||||
<li>allporncomic.com</li>
|
||||
<li>hentai2read.com</li>
|
||||
<li>mangadex.org</li>
|
||||
<li>Simpcity</li>
|
||||
<li>gelbooru.com</li>
|
||||
<li>Toonily.com</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Powerful Batch Mode</h3>
|
||||
<p>Save time by downloading hundreds of URLs at once. Simply type <b>nhentai.net</b> or <b>saint2.su</b> into the URL bar. The app will look for a <b>nhentai.txt</b> or <b>saint2.su.txt</b> file in your 'appdata' folder and process all the URLs inside it.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Advanced Discord Support</h3>
|
||||
<p>Go beyond simple file downloading. The app can connect directly to the Discord API to:</p>
|
||||
<ul>
|
||||
<li>Download all files from a specific channel.</li>
|
||||
<li>Save an entire channel's message history as a fully formatted PDF.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Advanced Filtering",
|
||||
"""
|
||||
<p>Control exactly what content you download, from broad categories to specific keywords.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Content Type Filters</h3>
|
||||
<p>These radio buttons let you select the main <i>type</i> of content you want:</p>
|
||||
<ul>
|
||||
<li><b>All:</b> Downloads everything (default).</li>
|
||||
<li><b>Images/GIFs:</b> Only downloads static images and GIFs.</li>
|
||||
<li><b>Videos:</b> Only downloads video files (MP4, WEBM, MOV, etc.).</li>
|
||||
<li><b>Only Archives:</b> Exclusively downloads .zip and .rar files.</li>
|
||||
<li><b>Only Links:</b> Extracts external links (Mega, Google Drive) from post descriptions instead of downloading.</li>
|
||||
<li><b>Only Audio:</b> Only downloads audio files (MP3, WAV, etc.).</li>
|
||||
<li><b>More:</b> Opens a dialog to download post descriptions or comments as text/PDF.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Character Filtering</h3>
|
||||
<p>The <b>"Filter by Character(s)"</b> input is your most powerful tool for targeting content.</p>
|
||||
<ul>
|
||||
<li><b>Basic Use:</b> Enter names, separated by commas (e.g., <code>Tifa, Aerith</code>). This will create folders for "Tifa" and "Aerith" and download posts matching those names.</li>
|
||||
<li><b>Grouped Aliases:</b> Use parentheses to group aliases for a single character (e.g., <code>(Tifa, Lockhart)</code>). This still creates a "Tifa" folder, but it will also match posts that just say "Lockhart".</li>
|
||||
</ul>
|
||||
<p>The <b>"Filter: [Scope]"</b> button changes <i>what</i> is scanned:</p>
|
||||
<ul>
|
||||
<li><b>Filter: Title (Default):</b> Scans only the post's main title.</li>
|
||||
<li><b>Filter: Files:</b> Scans the <i>filenames</i> within the post.</li>
|
||||
<li><b>Filter: Both:</b> Scans both the title and the filenames.</li>
|
||||
<li><b>Filter: Comments (Beta):</b> Scans the post's comment section for the keywords.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Skip Filters (Avoid Content)</h3>
|
||||
<p>The <b>"Skip with Words"</b> input lets you avoid content you don't want.</p>
|
||||
<p>The <b>"Scope: [Scope]"</b> button changes <i>how</i> it skips:</p>
|
||||
<ul>
|
||||
<li><b>Scope: Posts (Default):</b> Skips the <i>entire post</i> if the post's title contains a skip word (e.g., <code>WIP, sketch</code>).</li>
|
||||
<li><b>Scope: Files:</b> Scans and skips <i>individual files</i> if their filename contains a skip word.</li>
|
||||
<li><b>Scope: Both:</b> Skips the post if the title matches, and if not, still checks individual files.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Other Content Options</h3>
|
||||
<ul>
|
||||
<li><b>Skip .zip:</b> A quick toggle to ignore all archive files.</li>
|
||||
<li><b>Download Thumbnails Only:</b> Downloads the small preview image instead of the full-resolution file.</li>
|
||||
<li><b>Scan Content for Images:</b> Scans the post's text description for <code><img></code> tags. Useful for embedded images not in the post's attachment list.</li>
|
||||
<li><b>Keep Duplicates:</b> By default, the app skips files with identical content (hash). Check this to open a dialog and configure it to keep duplicate files.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Filename Control</h3>
|
||||
<p>The <b>"Remove Words from name"</b> input cleans up filenames. Any text you enter here (comma-separated) will be removed from the final saved filename (e.g., <code>patreon, exclusive</code>).</p>
|
||||
"""),
|
||||
|
||||
("Folder Management (Known.txt)",
|
||||
"""
|
||||
<p>This feature, enabled by the <b>"Separate Folders by Known.txt"</b> checkbox, automatically sorts your downloads. It's designed mainly for <b>Kemono</b>, where creators often tag posts with character names in the title.</p>
|
||||
|
||||
<p>When you download from a creator, this feature checks each <b>post title</b> against your `Known.txt` list. If a name matches, a folder is created for that name, and all posts from that creator mentioning the name will be <b>grouped together</b> in that single folder.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Folder Naming Priority</h3>
|
||||
<p>When "Separate Folders" is checked, the app uses this priority to name folders:</p>
|
||||
<ol>
|
||||
<li><b>Character Filter:</b> If you use the <b>"Filter by Character(s)"</b> input (e.g., <code>Tifa</code>), that name is <b>always</b> used as the folder name. This overrides all other rules.</li>
|
||||
<li><b>Known.txt (Post Title):</b> If no filter is used, it checks the <b>post's title</b> for a name in `Known.txt`. (This is the most common use case).</li>
|
||||
<li><b>Known.txt (Filename):</b> If the title doesn't match, it checks all <b>filenames</b> in the post for a match in `Known.txt`.</li>
|
||||
<li><b>Fallback:</b> If no match is found, it creates a generic folder from the post's title.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Editing Your Known.txt File</h3>
|
||||
<p>You can manage this list using the panel on the right of the main window or by clicking <b>"Open Known.txt"</b> to edit it directly. There are two formats:</p>
|
||||
<ul>
|
||||
<li><b>Simple Name:</b><br>
|
||||
<code>Tifa</code><br>
|
||||
This creates a folder named "Tifa" and matches posts/files named "Tifa".
|
||||
</li>
|
||||
<br>
|
||||
<li><b>Grouped Aliases:</b><br>
|
||||
<code>(Tifa, Lockhart)</code><br>
|
||||
This is the most powerful format. It creates a folder named <b>"Tifa Lockhart"</b> and will match posts/files that contain either "Tifa" <i>or</i> "Lockhart". This is perfect for characters with multiple names.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Important Note:</h3>
|
||||
<p>This automatic sorting <b>only works if the creator includes the character names or keywords in the post title</b> (or filename). If they don't, the app has no way of knowing how to sort the post, and it will fall back to a generic folder name.</p>
|
||||
"""),
|
||||
|
||||
("Renaming Mode",
|
||||
"""
|
||||
<p>This mode is designed for downloading comics, manga, or any multi-file post where you need files to be in a specific, sequential order. When active, it downloads posts from <b>oldest to newest</b>.</p>
|
||||
|
||||
<p>Activate it by checking the <b>"Renaming Mode"</b> checkbox. This reveals a new button: <b>"Name: [Style]"</b>. Clicking this button cycles through all available naming conventions.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Available Naming Styles</h3>
|
||||
<ul>
|
||||
<li><b>Post Title:</b> (Default) Files are named after the post's title, with a number for multi-file posts (e.g., <code>My Comic Page_1.jpg</code>, <code>My Comic Page_2.jpg</code>).</li>
|
||||
|
||||
<li><b>Date + Original:</b> Prepends the post's date to the original filename (e.g., <code>2025-11-16_original_file_name.jpg</code>).</li>
|
||||
|
||||
<li><b>Date + Title:</b> Prepends the date to the post title (e.g., <code>2025-11-16_My Comic Page_1.jpg</code>).</li>
|
||||
|
||||
<li><b>Post ID:</b> Names files using the post's unique ID and the file index (e.g., <code>9876543_0.jpg</code>, <code>9876543_1.jpg</code>).</li>
|
||||
|
||||
<li><b>Date Based:</b> Renames all files to a simple, sequential number (e.g., <code>001.jpg</code>, <code>002.jpg</code>). You can add a prefix in the text box that appears (e.g., "Chapter 1 " to get <code>Chapter 1 001.jpg</code>).
|
||||
<br><b style='color: #f0ad4e;'>Note: This mode disables multithreading to guarantee correct file order.</b></li>
|
||||
|
||||
<li><b>Title + G.Num (Global Numbering):</b> Names files by title, but with a *global* counter (e.g., <code>Post A_001.jpg</code>, <code>Post B_002.jpg</code>).
|
||||
<br><b style='color: #f0ad4e;'>Note: This mode also disables multithreading.</b></li>
|
||||
|
||||
<li><b>Custom:</b> Lets you design your own filename using a format string. A <b>"..."</b> button will appear to open the custom format dialog.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Custom Format Placeholders</h3>
|
||||
<p>When using the "Custom" style, you can use these placeholders (click the buttons in the dialog to add them):</p>
|
||||
<ul>
|
||||
<li><code>{id}</code> - The unique ID of the post.</li>
|
||||
<li><code>{creator_name}</code> - The creator's name.</li>
|
||||
<li><code>{service}</code> - The service (e.g., Patreon, Pixiv Fanbox, etc).</li>
|
||||
<li><code>{title}</code> - The title of the post.</li>
|
||||
<li><code>{added}</code> - Date the post was added.</li>
|
||||
<li><code>{published}</code> - Date the post was published.</li>
|
||||
<li><code>{edited}</code> - Date the post was last edited.</li>
|
||||
<li><code>{name}</code> - The original name of the file.</li>
|
||||
</ul>
|
||||
<p>You can also set a custom <b>Date Format</b> (e.g., <code>YYYY-MM-DD</code>) that will apply to the {added}, {published}, and {edited} placeholders.</p>
|
||||
"""),
|
||||
|
||||
("Batch Downloading",
|
||||
"""
|
||||
<p>This feature allows you to download hundreds of URLs from a text file, which is much faster than queuing them one by one.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>How It Works (Step-by-Step)</h3>
|
||||
<ol>
|
||||
<li><b>Find your 'appdata' folder:</b> This is in the same directory as the downloader's <code>.exe</code> file.</li>
|
||||
<li><b>Create a .txt file:</b> Inside the 'appdata' folder, create a text file for the site you want to batch from. The name must be exact. (eg.. nhentai.txt, hentai2read.txt, etc.. )</li>
|
||||
<li><b>Add URLs:</b> Open the <code>.txt</code> file and paste one download URL on each line. Save the file.</li>
|
||||
<li><b>Start the Batch:</b> In the downloader's main URL bar, type the <b>site's domain name</b> (e.g., <code>nhentai.net</code>) and click "Start Download".</li>
|
||||
</ol>
|
||||
<p>The app will automatically find your text file, read all the URLs, and download them sequentially.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Supported Sites and Filenames</h3>
|
||||
<p>The <code>.txt</code> file name must match the site you are triggering:</p>
|
||||
<ul>
|
||||
<li><b>To trigger, type:</b> <code>allporncomic.com</code><br>
|
||||
<b>Text file name:</b> <code>allporncomic.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>nhentai.net</code><br>
|
||||
<b>Text file name:</b> <code>nhentai.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>fap-nation.com</code> or <code>fap-nation.org</code><br>
|
||||
<b>Text file name:</b> <code>fap-nation.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>saint2.su</code><br>
|
||||
<b>Text file name:</b> <code>saint2.su.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>hentai2read.com</code><br>
|
||||
<b>Text file name:</b> <code>hentai2read.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>rule34video.com</code><br>
|
||||
<b>Text file name:</b> <code>rule34video.txt</code></li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Special Modes: Text & Links",
|
||||
"""
|
||||
<p>These two modes completely change the downloader's function from downloading files to extracting information.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>🔗 Only Links Mode</h3>
|
||||
<p>When you select this, the app <b>stops downloading files</b>. Instead, it scans the post's description for any external URLs (like Mega, Google Drive, Dropbox, etc.) and lists them in the main log.</p>
|
||||
<p>This mode also reveals a new set of tools above the log:</p>
|
||||
<ul>
|
||||
<li><b>Search Bar:</b> Lets you filter the extracted links by keyword (e.g., "mega", "part 1").</li>
|
||||
<li><b>Export Links Button:</b> Opens a dialog to save all the found links to a <code>.txt</code> file.</li>
|
||||
<li><b>Download Button:</b> Opens a new dialog that lets you selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>📄 More (Text Export Mode)</h3>
|
||||
<p>This mode downloads the <b>text content</b> from posts instead of the files. When you select it, a dialog appears asking for more details:</p>
|
||||
<ul>
|
||||
<li><b>Scope:</b>
|
||||
<ul>
|
||||
<li><b>Description/Content:</b> Saves the text from the post's main body.</li>
|
||||
<li><b>Comments:</b> Fetches and saves all the comments from the post.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><b>Export as:</b> You can choose to save the text as a <b>PDF</b>, <b>DOCX</b>, or <b>TXT</b> file.</li>
|
||||
<li><b>Single PDF:</b> (Only available for PDF format) This powerful option stops the app from saving individual PDF files. Instead, it collects the text from <i>all</i> matching posts, sorts them by date, and compiles them into <b>one single, large PDF file</b> at the end of the download session.</li>
|
||||
</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>
|
||||
<p><b>Example:</b> <code>Tifa, (Cloud, Zack) [ao] [sfp-10]</code></p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Filter Commands (in "Filter by Character(s)" input)</h3>
|
||||
<ul>
|
||||
<li><b><code>[ao]</code> (Archive Only Priority)</b><br>
|
||||
This command prioritizes archives.
|
||||
<ul>
|
||||
<li>If a post contains <b>only images/videos</b>, it will download them normally.</li>
|
||||
<li>If a post contains <b>both archives AND images/videos</b>, this command tells the app to <b>only download the archives</b> and skip the other files for that post.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[sfp-N]</code> (Subfolder Per Post Threshold)</b><br>
|
||||
This is an override for when "Subfolder per Post" is <b>OFF</b> (and "Separate Folders by Known.txt" is <b>ON</b>).<br>
|
||||
For example, if you set <code>[sfp-10]</code>:
|
||||
<ul>
|
||||
<li>Posts with <b>less than 10 files</b> will download normally into the main folder (e.g., <code>/ArtistName/</code>).</li>
|
||||
<li>When a post with <b>10 or more files</b> is found, this command will <b>force a subfolder to be created for that one post</b> (e.g., <code>/ArtistName/Comic_Title/</code>) to keep its files grouped together.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[unknown]</code> (Handle Unknown)</b><br>
|
||||
Changes how sorting works when "Separate Folders by Known.txt" is on. If a post title doesn't match any name in your <code>Known.txt</code> list, this command will create a folder using the post's title instead of a generic fallback folder.
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[.domain]</code> (Domain Override)</b><br>
|
||||
An advanced command. For example, <code>[.st]</code> forces the app to download from <code>coomer.st</code>, and <code>[.cr]</code> forces it to download from <code>kemono.cr</code>. This can be useful if one domain is blocked or slow.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Skip Command (in "Skip with Words" input)</h3>
|
||||
<p>This command is different and goes into the <b>"Skip with Words"</b> input field, along with any other skip words.</p>
|
||||
<ul>
|
||||
<li><b><code>[N]</code> (Skip File by Size)</b><br>
|
||||
This command skips any file that is <b>smaller</b> than <code>N</code> megabytes (MB).<br>
|
||||
<b>Example:</b> Entering <code>WIP, sketch, [200]</code> into the "Skip with Words" input will skip files with "WIP" or "sketch" in their name, AND it will also skip any file smaller than 200MB.
|
||||
</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Cloud Storage & Direct Links",
|
||||
"""
|
||||
<p>The downloader has built-in support for popular cloud storage and direct-link sites. You can use this in two main ways.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Method 1: Direct URL Download</h3>
|
||||
<p>You can paste a direct link from these services into the main URL bar and hit "Start Download" just like a Kemono link.</p>
|
||||
<ul>
|
||||
<li><b>Pixeldrain:</b> Supports single files (<code>/u/...</code>), albums (<code>/l/...</code>), and folders (<code>/d/...</code>).</li>
|
||||
<li><b>Mega.nz:</b> Supports both single file links (<code>/file/...</code>) and folder links (<code>/folder/...</code>).</li>
|
||||
<li><b>Gofile.io:</b> Supports folder links (<code>/d/...</code>).</li>
|
||||
<li><b>Google Drive:</b> Supports shared folder links.</li>
|
||||
<li><b>Dropbox:</b> Supports shared <code>.zip</code> file links. It will automatically download, extract, and delete the <code>.zip</code> file.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Method 2: "Only Links" Mode Downloader</h3>
|
||||
<p>This is a two-step process for handling posts that have many cloud links in their description.</p>
|
||||
<ol>
|
||||
<li><b>Step 1: Extract Links</b><br>
|
||||
Select the <b>"🔗 Only Links"</b> radio button and run a download on a creator or post page. The app will scan all posts and list the external links (Mega, GDrive, etc.) it finds in the log.
|
||||
</li>
|
||||
<br>
|
||||
<li><b>Step 2: Download Links</b><br>
|
||||
After extraction, a <b>"Download"</b> button (next to "Export Links") will become active. This opens a new window where you can selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Note: SimpCity Integration</h3>
|
||||
<p>SimpCity support relies heavily on this feature. When you download from a SimpCity thread, the app <b>automatically</b> scans the page for links to services like <b>Pixeldrain, Bunkr, Saint2, Mega, and Gofile</b> and then downloads them just as if you had put in those links directly. You can control which of these services are downloaded from the checkboxes in the "SimpCity Settings" section of the main window.</p>
|
||||
"""),
|
||||
|
||||
("Creator Selection & Updates",
|
||||
"""
|
||||
<p>Clicking the <b>🎨 button</b> (next to the URL bar) opens the <b>Creator Selection</b> dialog. This is your control for managing creators you've already downloaded from.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Main List & Searching</h3>
|
||||
<p>The main list shows all creators from your <code>creators.json</code> file. You can:</p>
|
||||
<ul>
|
||||
<li><b>Search:</b> The top search bar filters your creators by name, service, or even a direct URL.</li>
|
||||
<li><b>Select:</b> Check the boxes next to creators to select them for an action.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Action Buttons</h3>
|
||||
|
||||
<p><b>Check for Updates</b></p>
|
||||
<p>This button opens a new window, "Check for Updates," which lists all your <b>Creator Profiles</b> (the <code>.json</code> files saved in your <code>appdata/creator_profiles</code> folder). These profiles are created automatically when you download a full creator page.</p>
|
||||
<p>From this dialog, you can check multiple creators at once. The app will scan all of them and then show a final "Start Download" button on the main window to download <i>only</i> the new posts, using the same settings you used for each creator last time.</p>
|
||||
|
||||
<p><b>Add Selected</b></p>
|
||||
<p>This is the simplest action. It takes all the creators you've checked, puts their names in the main URL bar, and closes the dialog. This is a quick way to add multiple creators to the download queue for a download.</p>
|
||||
|
||||
<p><b>Fetch Posts</b></p>
|
||||
<p>This is a powerful tool for finding specific posts. When you click it:</p>
|
||||
<ol>
|
||||
<li>The dialog expands, and a new panel appears on the right.</li>
|
||||
<li>The app fetches <i>every single post</i> from all the creators you selected. This may take time.</li>
|
||||
<li>The right panel fills with a list of all posts, grouped by creator.</li>
|
||||
<li>You can now search this list and check the boxes next to the <i>individual posts</i> you want.</li>
|
||||
<li>Clicking <b>"Add Selected Posts to Queue"</b> adds only those specific posts to the download queue.</li>
|
||||
</ol>
|
||||
"""),
|
||||
|
||||
("⭐ Favorite Mode",
|
||||
"""
|
||||
<p>This mode is a powerful feature for downloading directly from your personal <b>Kemono</b> and <b>Coomer</b> favorites lists. It requires you to be logged in on your browser and to provide your cookies to the app.</p>
|
||||
|
||||
<p><b style='color: #f0ad4e;'>Important:</b> You <b>must</b> check the <b>"Use Cookie"</b> box and provide a valid cookie for this mode to work. If cookies are missing or invalid, the app will show you a help dialog.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>How to Use Favorite Mode</h3>
|
||||
<ol>
|
||||
<li>Check the <b>"⭐ Favorite Mode"</b> checkbox on the main window. This will lock the URL bar and show two new buttons.</li>
|
||||
<li>Click either <b>"🖼️ Favorite Artists"</b> or <b>"📄 Favorite Posts"</b>.</li>
|
||||
<li>A new dialog will open and begin fetching all your favorites from both Kemono and Coomer at the same time.</li>
|
||||
<li>Once loaded, you can search, filter, and select the artists or posts you want to download.</li>
|
||||
<li>Click "Download Selected" to add them to the main download queue and begin processing.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Favorite Artists</h3>
|
||||
<p>The <b>"Favorite Artists"</b> dialog will load your list of followed creators. When you download from here, the app treats it as a full creator download, just as if you had pasted in that artist's URL.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Favorite Posts</h3>
|
||||
<p>The <b>"Favorite Posts"</b> dialog loads a list of every individual post you have favorited. This dialog has some extra features:</p>
|
||||
<ul>
|
||||
<li><b>Creator Name Resolution:</b> It attempts to match the post's creator ID with the names in your <code>creators.json</code> file to show you a recognizable name.</li>
|
||||
<li><b>Known.txt Matching:</b> It highlights posts by showing <code>[Known - Tifa]</code> in the title if the post title matches an entry in your <code>Known.txt</code> list, helping you find specific content.</li>
|
||||
<li><b>Grouping:</b> Posts are automatically grouped by creator to keep the list organized.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Download Scope (Artist Folders)</h3>
|
||||
<p>In Favorite Mode, the <b>"Scope: [Location]"</b> button becomes very important. It controls <i>where</i> your favorited items are saved:</p>
|
||||
<ul>
|
||||
<li><b>Scope: Selected Location (Default):</b> Downloads all selected items directly into the main "Download Location" folder you have set.</li>
|
||||
<li><b>Scope: Artist Folders:</b> This automatically creates a new subfolder for each artist inside your main "Download Location" (e.g., <code>/Downloads/ArtistName/</code>). This is the best way to keep your favorites organized.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("File & Download Options",
|
||||
"""
|
||||
<p>These checkboxes give you fine-grained control over which files are downloaded and how they are processed.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>File Type & Content</h3>
|
||||
<ul>
|
||||
<li><b>Skip .zip:</b> A simple toggle. When checked, the downloader will skip all <code>.zip</code> and <code>.rar</code> archive files it finds.</li>
|
||||
<br>
|
||||
<li><b>Scan Content for Images:</b> This is a powerful feature for posts where images are embedded in the description (<code><img></code> tags) but not listed as attachments. When checked, the app will scan the post's HTML content and try to find and download these embedded images.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Image Processing</h3>
|
||||
<ul>
|
||||
<li><b>Download Thumbnails Only:</b> Saves bandwidth and time by downloading the small preview/thumbnail version of an image instead of the full-resolution file.</li>
|
||||
<br>
|
||||
<li><b>Compress to WebP:</b> If an image is over 1.5MB, this option will automatically convert it to the <code>.webp</code> format during the download, which significantly reduces file size while maintaining high quality.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Duplicate Handling</h3>
|
||||
<ul>
|
||||
<li><b>Keep Duplicates:</b> By default, the app checks the <i>content</i> (hash) of a file and will not re-download a file it already has. Checking this box opens a dialog with more options:
|
||||
<ul>
|
||||
<li><b>Hash (Default):</b> The standard behavior.</li>
|
||||
<li><b>Keep Everything:</b> Disables all duplicate checks and downloads every file from the API, even if you already have it.</li>
|
||||
<li><b>Limit:</b> Lets you set a limit (e.g., 2) to how many times a file with the same content can be downloaded.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("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>
|
||||
<li><b>Paste a cookie string:</b> Copy the "cookie" value from your browser's developer tools and paste it into the text field.</li>
|
||||
<li><b>Use a file:</b> Click the "Browse" button to select a <code>cookies.txt</code> file exported from your browser.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Page Range</h3>
|
||||
<p>When downloading from a creator's main page (not a single post), these "Start" and "End" fields let you limit the download. For example, entering <code>Start: 1</code> and <code>End: 5</code> will only download posts from the first five pages.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Multi-part Download</h3>
|
||||
<p>Clicking the <b>"Multi-part: OFF"</b> button opens a dialog to enable high-speed downloads for large files. It will split a large file into multiple parts and download them at the same time. You can choose to apply this to videos, archives, or both, and set the minimum file size to trigger it.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Download History</h3>
|
||||
<p>The <b>"History"</b> button opens a dialog showing two lists:</p>
|
||||
<ul>
|
||||
<li><b>Last 3 Files:</b> The last 3 individual files you successfully downloaded.</li>
|
||||
<li><b>First 3 Posts:</b> The first 3 posts Processed from your *most recent* download session.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Settings (Gear Icon)</h3>
|
||||
<p>The <b>Gear</b> icon ⚙️ opens the main application settings, which is now organized into tabs:</p>
|
||||
<ul>
|
||||
<li><b>Display Tab:</b> Change the app's <b>Theme</b> (Light/Dark), <b>Language</b>, <b>UI Scale</b>, and default <b>Window Size</b>.</li>
|
||||
<li><b>Downloads Tab:</b>
|
||||
<ul>
|
||||
<li>Save your current <b>Download Path</b>, <b>Cookie</b>, and <b>Discord Token</b> for future sessions using the "Save Path + Cookie + Token" button.</li>
|
||||
<li>Set an <b>Action After Download</b> (e.g., Notify, Sleep, Shutdown).</li>
|
||||
<li>Customize the <b>Post Subfolder Format</b> for when the date prefix is used (e.g., <code>YYYY-MM-DD {post}</code>).</li>
|
||||
<li>Toggle <b>"Save Creator.json file"</b> (which enables the "Check for Updates" feature).</li>
|
||||
<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>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Reset Button</h3>
|
||||
<p>The <b>"Reset"</b> button (bottom right) is a soft reset. It clears all input fields (except your Download Location), clears the logs, and resets all download options and filters back to their default state. It does <b>not</b> clear your Download History or saved Settings.</p>
|
||||
""")
|
||||
]
|
||||
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
|
||||
@@ -66,7 +554,38 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
current_theme_style = ""
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
base_style = get_dark_theme(scale)
|
||||
|
||||
list_widget_style = f"""
|
||||
QListWidget {{
|
||||
background-color: #2E2E2E;
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
color: #DCDCDC;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #4A4A4A;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #87CEEB;
|
||||
color: #1E1E1E;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: #3A3A3A;
|
||||
}}
|
||||
|
||||
/* Style for the TourStepWidget content */
|
||||
TourStepWidget QLabel {{
|
||||
color: #DCDCDC;
|
||||
}}
|
||||
TourStepWidget QScrollArea {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
"""
|
||||
current_theme_style = base_style + list_widget_style
|
||||
else:
|
||||
# Basic light theme fallback
|
||||
current_theme_style = f"""
|
||||
@@ -83,29 +602,50 @@ class HelpGuideDialog(QDialog):
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #CACACA; }}
|
||||
QPushButton:pressed {{ background-color: #B0B0B0; }}
|
||||
QListWidget {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #C0C0C0;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
color: #1E1E1E;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #0078D7;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: #F0F0F0;
|
||||
}}
|
||||
TourStepWidget QLabel {{
|
||||
color: #1E1E1E;
|
||||
}}
|
||||
TourStepWidget h3 {{
|
||||
color: #005A9E;
|
||||
}}
|
||||
"""
|
||||
|
||||
self.setStyleSheet(current_theme_style)
|
||||
self._init_ui()
|
||||
|
||||
if self.parent_app:
|
||||
self.move(self.parent_app.geometry().center() - self.rect().center())
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _init_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(15, 15, 15, 15)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# Title
|
||||
title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
|
||||
title_label = QLabel("Kemono Downloader - Feature Guide")
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
title_font_size = int(16 * scale)
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;")
|
||||
# Use a consistent color for the main title
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB;")
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title_label)
|
||||
|
||||
@@ -114,35 +654,16 @@ class HelpGuideDialog(QDialog):
|
||||
main_layout.addLayout(content_layout, 1)
|
||||
|
||||
self.nav_list = QListWidget()
|
||||
self.nav_list.setFixedWidth(int(220 * scale))
|
||||
self.nav_list.setStyleSheet(f"""
|
||||
QListWidget {{
|
||||
background-color: #2E2E2E;
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #4A4A4A;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #87CEEB;
|
||||
color: #2E2E2E;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
# 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)
|
||||
|
||||
self.stacked_widget = QStackedWidget()
|
||||
content_layout.addWidget(self.stacked_widget)
|
||||
|
||||
for title_key, content_key in self.steps_data:
|
||||
title = self._tr(title_key, title_key)
|
||||
content = self._tr(content_key, f"Content for {content_key} not found.")
|
||||
|
||||
for title, content in self.steps_data:
|
||||
self.nav_list.addItem(title)
|
||||
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
@@ -171,13 +692,19 @@ class HelpGuideDialog(QDialog):
|
||||
icon_dim = int(24 * scale)
|
||||
icon_size = QSize(icon_dim, icon_dim)
|
||||
|
||||
tooltip_map = {
|
||||
"help_guide_github_tooltip": "Visit the project on GitHub",
|
||||
"help_guide_instagram_tooltip": "Follow the developer on Instagram",
|
||||
"help_guide_discord_tooltip": "Join the official Discord server"
|
||||
}
|
||||
|
||||
for button, tooltip_key, url in [
|
||||
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi63771/Kemono-Downloader"),
|
||||
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
|
||||
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
|
||||
]:
|
||||
button.setIconSize(icon_size)
|
||||
button.setToolTip(self._tr(tooltip_key))
|
||||
button.setToolTip(tooltip_map.get(tooltip_key, ""))
|
||||
button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
|
||||
button.setStyleSheet("background-color: transparent; border: none;")
|
||||
button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
|
||||
@@ -185,7 +712,7 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
footer_layout.addStretch(1)
|
||||
|
||||
self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
|
||||
self.finish_button = QPushButton("Finish")
|
||||
self.finish_button.clicked.connect(self.accept)
|
||||
footer_layout.addWidget(self.finish_button)
|
||||
|
||||
|
||||
@@ -11,17 +11,16 @@ class MoreOptionsDialog(QDialog):
|
||||
SCOPE_CONTENT = "content"
|
||||
SCOPE_COMMENTS = "comments"
|
||||
|
||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
|
||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False, add_info_checked=False):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent
|
||||
self.setWindowTitle("More Options")
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
# ... (Layout and other widgets remain the same) ...
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.description_label = QLabel("Please choose the scope for the action:")
|
||||
layout.addWidget(self.description_label)
|
||||
|
||||
self.radio_button_group = QButtonGroup(self)
|
||||
self.radio_content = QRadioButton("Description/Content")
|
||||
self.radio_comments = QRadioButton("Comments")
|
||||
@@ -50,14 +49,20 @@ class MoreOptionsDialog(QDialog):
|
||||
export_layout.addStretch()
|
||||
layout.addLayout(export_layout)
|
||||
|
||||
# --- UPDATED: Single PDF Checkbox ---
|
||||
# --- Single PDF Checkbox ---
|
||||
self.single_pdf_checkbox = QCheckBox("Single PDF")
|
||||
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
|
||||
self.single_pdf_checkbox.setChecked(single_pdf_checked)
|
||||
layout.addWidget(self.single_pdf_checkbox)
|
||||
|
||||
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
|
||||
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
|
||||
# --- NEW: Add Info Checkbox ---
|
||||
self.add_info_checkbox = QCheckBox("Add info in PDF")
|
||||
self.add_info_checkbox.setToolTip("If checked, adds a first page with post details (Title, Date, Link, Creator, Tags, etc.).")
|
||||
self.add_info_checkbox.setChecked(add_info_checked)
|
||||
layout.addWidget(self.add_info_checkbox)
|
||||
|
||||
self.format_combo.currentTextChanged.connect(self.update_checkbox_states)
|
||||
self.update_checkbox_states(self.format_combo.currentText())
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
@@ -65,12 +70,18 @@ class MoreOptionsDialog(QDialog):
|
||||
layout.addWidget(self.button_box)
|
||||
self.setLayout(layout)
|
||||
self._apply_theme()
|
||||
def update_single_pdf_checkbox_state(self, text):
|
||||
"""Enable the Single PDF checkbox only if the format is PDF."""
|
||||
|
||||
def update_checkbox_states(self, text):
|
||||
"""Enable PDF-specific checkboxes only if the format is PDF."""
|
||||
is_pdf = (text.upper() == "PDF")
|
||||
self.single_pdf_checkbox.setEnabled(is_pdf)
|
||||
self.add_info_checkbox.setEnabled(is_pdf)
|
||||
|
||||
if not is_pdf:
|
||||
self.single_pdf_checkbox.setChecked(False)
|
||||
# We don't uncheck add_info necessarily, just disable it,
|
||||
# but unchecking is safer visually to imply "won't happen"
|
||||
self.add_info_checkbox.setChecked(False)
|
||||
|
||||
def get_selected_scope(self):
|
||||
if self.radio_comments.isChecked():
|
||||
@@ -84,13 +95,14 @@ class MoreOptionsDialog(QDialog):
|
||||
"""Returns the state of the Single PDF checkbox."""
|
||||
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
|
||||
|
||||
def get_add_info_state(self):
|
||||
"""Returns the state of the Add Info checkbox."""
|
||||
return self.add_info_checkbox.isChecked() and self.add_info_checkbox.isEnabled()
|
||||
|
||||
def _apply_theme(self):
|
||||
"""Applies the current theme from the parent application."""
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
# Get the scale factor from the parent app
|
||||
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
# Call the imported function with the correct scale
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
# Explicitly set a blank stylesheet for light mode
|
||||
self.setStyleSheet("")
|
||||
self.setStyleSheet("")
|
||||
@@ -4,24 +4,22 @@ try:
|
||||
from fpdf import FPDF
|
||||
FPDF_AVAILABLE = True
|
||||
|
||||
# --- FIX: Move the class definition inside the try block ---
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.font_family_main = 'Arial'
|
||||
|
||||
def header(self):
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
if self.font_family:
|
||||
self.set_font(self.font_family, '', 8)
|
||||
else:
|
||||
self.set_font('Arial', '', 8)
|
||||
self.set_font(self.font_family_main, '', 8)
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
except ImportError:
|
||||
FPDF_AVAILABLE = False
|
||||
# If the import fails, FPDF and PDF will not be defined,
|
||||
# but the program won't crash here.
|
||||
FPDF = None
|
||||
PDF = None
|
||||
|
||||
@@ -31,12 +29,169 @@ def strip_html_tags(text):
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
||||
def _setup_pdf_fonts(pdf, font_path, logger=print):
|
||||
"""Helper to setup fonts for the PDF instance."""
|
||||
bold_font_path = ""
|
||||
default_font = 'Arial'
|
||||
|
||||
if font_path:
|
||||
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
||||
|
||||
try:
|
||||
if font_path and os.path.exists(font_path):
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
default_font = 'DejaVu'
|
||||
if os.path.exists(bold_font_path):
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
else:
|
||||
pdf.add_font('DejaVu', 'B', font_path, uni=True)
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font = 'Arial'
|
||||
|
||||
pdf.font_family_main = default_font
|
||||
return default_font
|
||||
|
||||
def add_metadata_page(pdf, post, font_family):
|
||||
"""Adds a dedicated metadata page to the PDF with clickable links."""
|
||||
pdf.add_page()
|
||||
pdf.set_font(font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='C')
|
||||
pdf.ln(10)
|
||||
pdf.set_font(font_family, '', 11)
|
||||
|
||||
def add_info_row(label, value, link_url=None):
|
||||
if not value: return
|
||||
|
||||
# Write Label (Bold)
|
||||
pdf.set_font(font_family, 'B', 11)
|
||||
pdf.write(8, f"{label}: ")
|
||||
|
||||
# Write Value
|
||||
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
|
||||
pdf.multi_cell(w=0, h=8, txt=str(value), link=link_url)
|
||||
|
||||
# Reset styles
|
||||
pdf.set_text_color(0, 0, 0)
|
||||
pdf.set_font(font_family, '', 11)
|
||||
else:
|
||||
pdf.set_font(font_family, '', 11)
|
||||
pdf.multi_cell(w=0, h=8, txt=str(value))
|
||||
|
||||
pdf.ln(2)
|
||||
|
||||
date_str = post.get('published') or post.get('added') or 'Unknown'
|
||||
add_info_row("Date Uploaded", date_str)
|
||||
|
||||
creator = post.get('creator_name') or post.get('user') or 'Unknown'
|
||||
add_info_row("Creator", creator)
|
||||
|
||||
add_info_row("Service", post.get('service', 'Unknown'))
|
||||
|
||||
link = post.get('original_link')
|
||||
if not link and post.get('service') and post.get('user') and post.get('id'):
|
||||
link = f"https://kemono.su/{post['service']}/user/{post['user']}/post/{post['id']}"
|
||||
|
||||
# Pass 'link' as both the text value AND the URL target
|
||||
add_info_row("Original Link", link, link_url=link)
|
||||
|
||||
tags = post.get('tags')
|
||||
if tags:
|
||||
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
|
||||
add_info_row("Tags", tags_str)
|
||||
|
||||
pdf.ln(10)
|
||||
pdf.cell(0, 0, border='T')
|
||||
pdf.ln(10)
|
||||
|
||||
def create_individual_pdf(post_data, output_filename, font_path, add_info_page=False, add_comments=False, logger=print):
|
||||
"""
|
||||
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
|
||||
Creates a PDF for a single post.
|
||||
Supports optional metadata page and appending comments.
|
||||
"""
|
||||
if not FPDF_AVAILABLE:
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library not installed.")
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
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()
|
||||
|
||||
# Only add the Title header manually if we didn't add the info page
|
||||
# (Because the info page already contains the title at the top)
|
||||
if not add_info_page:
|
||||
pdf.set_font(font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, txt=post_data.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
content_text = post_data.get('content_text_for_pdf')
|
||||
comments_list = post_data.get('comments_list_for_pdf')
|
||||
|
||||
# 1. Write Content
|
||||
if content_text:
|
||||
pdf.set_font(font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, txt=content_text)
|
||||
pdf.ln(10)
|
||||
|
||||
# 2. Write Comments (if enabled and present)
|
||||
if comments_list and (add_comments or not content_text):
|
||||
if add_comments and content_text:
|
||||
pdf.add_page()
|
||||
pdf.set_font(font_family, 'B', 14)
|
||||
pdf.cell(0, 10, "Comments", ln=True)
|
||||
pdf.ln(5)
|
||||
|
||||
for i, comment in enumerate(comments_list):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
|
||||
pdf.set_font(font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
pdf.set_font(font_family, 'B', 10)
|
||||
pdf.write(8, str(user))
|
||||
|
||||
pdf.set_font(font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(font_family, '', 11)
|
||||
pdf.multi_cell(w=0, h=7, txt=body)
|
||||
|
||||
if i < len(comments_list) - 1:
|
||||
pdf.ln(3)
|
||||
pdf.cell(w=0, h=0, border='T')
|
||||
pdf.ln(3)
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ Error saving PDF '{os.path.basename(output_filename)}': {e}")
|
||||
return False
|
||||
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, add_info_page=False, logger=print):
|
||||
"""
|
||||
Creates a single, continuous PDF from multiple posts.
|
||||
"""
|
||||
if not FPDF_AVAILABLE:
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed.")
|
||||
return False
|
||||
|
||||
if not posts_data:
|
||||
@@ -44,34 +199,21 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
default_font_family = 'DejaVu'
|
||||
font_family = _setup_pdf_fonts(pdf, font_path, logger)
|
||||
|
||||
bold_font_path = ""
|
||||
if font_path:
|
||||
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
|
||||
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for i, post in enumerate(posts_data):
|
||||
if i > 0:
|
||||
# This ensures every post after the first gets its own page.
|
||||
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()
|
||||
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
if not add_info_page:
|
||||
pdf.set_font(font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
if 'comments' in post and post['comments']:
|
||||
comments_list = post['comments']
|
||||
@@ -80,17 +222,17 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.set_font(font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
if user is not None:
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.set_font(font_family, 'B', 10)
|
||||
pdf.write(8, str(user))
|
||||
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.set_font(font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.set_font(font_family, '', 11)
|
||||
pdf.multi_cell(w=0, h=7, txt=body)
|
||||
|
||||
if comment_index < len(comments_list) - 1:
|
||||
@@ -98,7 +240,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
pdf.cell(w=0, h=0, border='T')
|
||||
pdf.ln(3)
|
||||
elif 'content' in post:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.set_font(font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
@@ -107,4 +249,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@@ -26,6 +26,11 @@ class UpdateCheckDialog(QDialog):
|
||||
self.parent_app = parent_app_ref
|
||||
self.user_data_path = user_data_path
|
||||
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
|
||||
|
||||
self._default_checkbox_tooltip = (
|
||||
"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()
|
||||
self._load_profiles()
|
||||
@@ -56,9 +61,21 @@ class UpdateCheckDialog(QDialog):
|
||||
self.list_widget = QListWidget()
|
||||
# No selection mode, we only care about checkboxes
|
||||
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
||||
# Connect signal to handle checkbox state changes
|
||||
self.list_widget.itemChanged.connect(self._handle_item_changed)
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
# --- All Buttons in One Horizontal Layout ---
|
||||
# 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)
|
||||
# -------------------------------------
|
||||
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6) # small even spacing between all buttons
|
||||
|
||||
@@ -97,6 +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"))
|
||||
# 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."""
|
||||
@@ -119,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})
|
||||
@@ -133,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)
|
||||
@@ -144,16 +161,42 @@ class UpdateCheckDialog(QDialog):
|
||||
self.check_button.setEnabled(False)
|
||||
self.select_all_button.setEnabled(False)
|
||||
self.deselect_all_button.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
|
||||
|
||||
self.list_widget.blockSignals(True)
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setCheckState(check_state)
|
||||
self.list_widget.blockSignals(False)
|
||||
|
||||
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 'Enable Editing' checkbox.
|
||||
"""
|
||||
checked_count = 0
|
||||
for i in range(self.list_widget.count()):
|
||||
if self.list_widget.item(i).checkState() == Qt.Checked:
|
||||
checked_count += 1
|
||||
|
||||
if checked_count > 1:
|
||||
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.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."""
|
||||
@@ -176,4 +219,21 @@ class UpdateCheckDialog(QDialog):
|
||||
|
||||
def get_selected_profiles(self):
|
||||
"""Returns the list of profile data selected by the user."""
|
||||
return self.selected_profiles_list
|
||||
return self.selected_profiles_list
|
||||
|
||||
def should_load_into_ui(self):
|
||||
"""
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,12 @@ def extract_post_info(url_string):
|
||||
|
||||
stripped_url = url_string.strip()
|
||||
|
||||
|
||||
# --- DeviantArt Check ---
|
||||
if 'deviantart.com' in stripped_url.lower() or 'fav.me' in stripped_url.lower():
|
||||
# This MUST return 'deviantart' as the first element
|
||||
return 'deviantart', 'placeholder_user', 'placeholder_id' # ----------------------
|
||||
|
||||
# --- Rule34Video Check ---
|
||||
rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
|
||||
if rule34video_match:
|
||||
|
||||
@@ -307,14 +307,18 @@ def setup_ui(main_app):
|
||||
simpcity_settings_label = QLabel("⚙️ SimpCity Download Options:")
|
||||
simpcity_settings_layout.addWidget(simpcity_settings_label)
|
||||
|
||||
# Checkbox row
|
||||
# Checkbox row
|
||||
simpcity_checkboxes_layout = QHBoxLayout()
|
||||
|
||||
main_app.simpcity_dl_images_cb = QCheckBox("Download Images")
|
||||
main_app.simpcity_dl_images_cb.setChecked(True) # Checked by default
|
||||
main_app.simpcity_dl_pixeldrain_cb = QCheckBox("Download Pixeldrain")
|
||||
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
|
||||
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
|
||||
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
|
||||
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile")
|
||||
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_images_cb)
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_pixeldrain_cb)
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_cb)
|
||||
@@ -324,7 +328,6 @@ def setup_ui(main_app):
|
||||
simpcity_settings_layout.addLayout(simpcity_checkboxes_layout)
|
||||
|
||||
# --- START NEW CODE ---
|
||||
# Create the second, dedicated set of cookie controls for SimpCity
|
||||
simpcity_cookie_layout = QHBoxLayout()
|
||||
simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
|
||||
simpcity_cookie_label = QLabel("Cookie:")
|
||||
@@ -344,7 +347,6 @@ def setup_ui(main_app):
|
||||
left_layout.addLayout(checkboxes_group_layout)
|
||||
|
||||
# --- Action Buttons & Remaining UI ---
|
||||
# ... (The rest of the setup_ui function remains unchanged)
|
||||
main_app.standard_action_buttons_widget = QWidget()
|
||||
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
|
||||
btn_layout.setContentsMargins(0, 10, 0, 0)
|
||||
@@ -354,6 +356,11 @@ def setup_ui(main_app):
|
||||
font.setBold(True)
|
||||
main_app.download_btn.setFont(font)
|
||||
main_app.download_btn.clicked.connect(main_app.start_download)
|
||||
|
||||
main_app.add_queue_btn = QPushButton("➕ Add to Queue")
|
||||
main_app.add_queue_btn.setToolTip("Save current settings as a job for later execution.")
|
||||
main_app.add_queue_btn.clicked.connect(main_app.add_current_settings_to_queue)
|
||||
|
||||
main_app.pause_btn = QPushButton("⏸️ Pause Download")
|
||||
main_app.pause_btn.setEnabled(False)
|
||||
main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action)
|
||||
@@ -364,6 +371,7 @@ def setup_ui(main_app):
|
||||
main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.")
|
||||
main_app.error_btn.setEnabled(True)
|
||||
btn_layout.addWidget(main_app.download_btn)
|
||||
btn_layout.addWidget(main_app.add_queue_btn)
|
||||
btn_layout.addWidget(main_app.pause_btn)
|
||||
btn_layout.addWidget(main_app.cancel_btn)
|
||||
btn_layout.addWidget(main_app.error_btn)
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
│ ├── DejaVuSansCondensed-BoldOblique.ttf
|
||||
│ ├── DejaVuSansCondensed-Oblique.ttf
|
||||
│ └── DejaVuSansCondensed.ttf
|
||||
├── directory_tree.txt
|
||||
├── main.py
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
|
||||
Reference in New Issue
Block a user