This commit is contained in:
Yuvi63771 2025-12-14 19:33:17 +05:30
parent 67faea0992
commit b5b6c1bc46
16 changed files with 1363 additions and 323 deletions

View File

@ -10,10 +10,9 @@ import queue
def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func): 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. 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() scraper = cloudscraper.create_scraper()
all_failed_files = [] # Track all failures across chapters
try: try:
progress_callback(" [Hentai2Read] Scraping series page for all metadata...") 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) final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
os.makedirs(final_save_path, exist_ok=True) os.makedirs(final_save_path, exist_ok=True)
# This function now scrapes and downloads simultaneously dl_count, skip_count, chapter_failures = _process_and_download_chapter(
dl_count, skip_count = _process_and_download_chapter(
chapter_url=chapter['url'], chapter_url=chapter['url'],
save_path=final_save_path, save_path=final_save_path,
scraper=scraper, scraper=scraper,
@ -51,9 +49,22 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
total_downloaded_count += dl_count total_downloaded_count += dl_count
total_skipped_count += skip_count total_skipped_count += skip_count
if chapter_failures:
all_failed_files.extend(chapter_failures)
overall_progress_callback(total_chapters, idx + 1) overall_progress_callback(total_chapters, idx + 1)
if check_pause_func(): break 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 return total_downloaded_count, total_skipped_count
except Exception as e: 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): def _get_series_metadata(start_url, progress_callback, scraper):
""" """
Scrapes the main series page to get the Artist Name, Series Title, and chapter list. 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 last_exception = None
soup = None soup = None
@ -77,8 +87,6 @@ def _get_series_metadata(start_url, progress_callback, scraper):
response = scraper.get(start_url, timeout=30) response = scraper.get(start_url, timeout=30)
response.raise_for_status() response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, 'html.parser')
# If successful, clear exception and break the loop
last_exception = None last_exception = None
break break
@ -86,8 +94,8 @@ def _get_series_metadata(start_url, progress_callback, scraper):
last_exception = e last_exception = e
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}") progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1: if attempt < max_retries - 1:
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s time.sleep(2 * (attempt + 1))
continue # Try again continue
if last_exception: if last_exception:
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {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: try:
series_title = "Unknown Series" series_title = "Unknown Series"
artist_name = None 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'): for b_tag in metadata_list.find_all('b'):
label = b_tag.get_text(strip=True) 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') a_tag = b_tag.find_next_sibling('a')
if a_tag: if a_tag:
artist_name = a_tag.get_text(strip=True) 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") chapter_links = soup.select("div.media a.pull-left.font-w600")
if not chapter_links: if not chapter_links:
@ -124,7 +145,7 @@ def _get_series_metadata(start_url, progress_callback, scraper):
] ]
chapters_to_process.reverse() 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.") progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
return top_level_folder_name, 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): def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
""" """
Uses a producer-consumer pattern to download a chapter. Uses a producer-consumer pattern to download a chapter.
The main thread (producer) scrapes URLs one by one. Includes RETRY LOGIC and ACTIVE LOGGING.
Worker threads (consumers) download the URLs as they are found.
""" """
task_queue = queue.Queue() task_queue = queue.Queue()
num_download_threads = 8 num_download_threads = 8
download_stats = {'downloaded': 0, 'skipped': 0} download_stats = {'downloaded': 0, 'skipped': 0}
failed_files_list = []
def downloader_worker(): def downloader_worker():
"""The function that each download thread will run."""
worker_scraper = cloudscraper.create_scraper() worker_scraper = cloudscraper.create_scraper()
while True: while True:
try: task = task_queue.get()
# Get a task from the queue if task is None:
task = task_queue.get() task_queue.task_done()
# The sentinel value to signal the end break
if task is None:
break filepath, img_url = task
filename = os.path.basename(filepath)
filepath, img_url = task
if os.path.exists(filepath): if os.path.exists(filepath):
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'") # We log skips to show it's checking files
download_stats['skipped'] += 1 progress_callback(f" -> Skip (Exists): '{filename}'")
else: download_stats['skipped'] += 1
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...") 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 = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
response.raise_for_status() response.raise_for_status()
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)
download_stats['downloaded'] += 1 download_stats['downloaded'] += 1
except Exception as e: success = True
progress_callback(f" ❌ Download failed for task. Error: {e}") # UNCOMMENTED: Log success
download_stats['skipped'] += 1 progress_callback(f" ✅ Downloaded: '{filename}'")
finally: break
task_queue.task_done()
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') executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
for _ in range(num_download_threads): for _ in range(num_download_threads):
executor.submit(downloader_worker) executor.submit(downloader_worker)
page_number = 1 page_number = 1
progress_callback(" [Hentai2Read] Scanning pages...") # Initial log
while True: while True:
if check_pause_func(): break if check_pause_func(): break
if page_number > 300: # Safety break if page_number > 300:
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.") progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
break 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}/" page_url_to_check = f"{chapter_url}{page_number}/"
try: try:
page_response = None page_response = None
page_last_exception = None page_last_exception = None
for page_attempt in range(3): # 3 attempts for sub-pages for page_attempt in range(3):
try: try:
page_response = scraper.get(page_url_to_check, timeout=30) page_response = scraper.get(page_url_to_check, timeout=30)
page_last_exception = None page_last_exception = None
break break
except Exception as e: except Exception as e:
page_last_exception = e page_last_exception = e
time.sleep(1) # Short delay for page scraping retries time.sleep(1)
if page_last_exception: 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: if page_response.history or page_response.status_code != 200:
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.") 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 img_src = img_tag.get("src") if img_tag else None
if not img_tag or img_src == "https://static.hentai.direct/hentai": 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 break
normalized_img_src = urljoin(page_response.url, img_src) 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)) task_queue.put((filepath, normalized_img_src))
page_number += 1 page_number += 1
time.sleep(0.1) # Small delay between scraping pages time.sleep(0.1)
except Exception as e: except Exception as e:
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}") progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
break break
# Signal workers to exit
for _ in range(num_download_threads): for _ in range(num_download_threads):
task_queue.put(None) task_queue.put(None)
# Wait for all tasks to complete
task_queue.join()
executor.shutdown(wait=True) executor.shutdown(wait=True)
progress_callback(f" Found and processed {page_number - 1} images for this chapter.") progress_callback(f" Chapter complete. Processed {page_number - 1} images.")
return download_stats['downloaded'], download_stats['skipped']
return download_stats['downloaded'], download_stats['skipped'], failed_files_list

View File

@ -23,7 +23,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
raise RuntimeError("Fetch operation cancelled by user while paused.") raise RuntimeError("Fetch operation cancelled by user while paused.")
time.sleep(0.5) time.sleep(0.5)
logger(" Post fetching resumed.") 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}' paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
max_retries = 3 max_retries = 3
@ -39,10 +39,10 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(log_message) logger(log_message)
try: try:
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) with requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) as response:
response.raise_for_status() response.raise_for_status()
response.encoding = 'utf-8' response.encoding = 'utf-8'
return response.json() return response.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
# Handle 403 error on the FIRST page as a rate limit/block # Handle 403 error on the FIRST page as a rate limit/block
@ -87,9 +87,10 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}" 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}...") 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: try:
scraper = cloudscraper.create_scraper()
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict) response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict)
response.raise_for_status() response.raise_for_status()
@ -104,6 +105,10 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
except Exception as e: except Exception as e:
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}") logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
return None return None
finally:
# CRITICAL FIX: Close the scraper session to free file descriptors and memory
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):
@ -115,10 +120,11 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
logger(f" Fetching comments: {comments_api_url}") logger(f" Fetching comments: {comments_api_url}")
try: try:
response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict) # FIX: Use context manager
response.raise_for_status() with requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict) as response:
response.encoding = 'utf-8' response.raise_for_status()
return response.json() response.encoding = 'utf-8'
return response.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise RuntimeError(f"Error fetching comments for post {post_id}: {e}") raise RuntimeError(f"Error fetching comments for post {post_id}: {e}")
except ValueError as e: except ValueError as e:
@ -174,10 +180,12 @@ def download_from_api(
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}" direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}") logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try: try:
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api) # FIX: Use context manager
direct_response.raise_for_status() with requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api) as direct_response:
direct_response.encoding = 'utf-8' direct_response.raise_for_status()
direct_post_data = direct_response.json() direct_response.encoding = 'utf-8'
direct_post_data = direct_response.json()
if isinstance(direct_post_data, list) and direct_post_data: if isinstance(direct_post_data, list) and direct_post_data:
direct_post_data = direct_post_data[0] 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): if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
@ -311,7 +319,6 @@ def download_from_api(
current_page_num = start_page current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).") logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
# --- START OF MODIFIED BLOCK ---
while True: while True:
if pause_event and pause_event.is_set(): if pause_event and pause_event.is_set():
logger(" Post fetching loop paused...") logger(" Post fetching loop paused...")
@ -334,7 +341,6 @@ def download_from_api(
break break
try: 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)
if not isinstance(raw_posts_batch, list): if not isinstance(raw_posts_batch, list):
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).") logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
@ -350,7 +356,6 @@ def download_from_api(
traceback.print_exc() traceback.print_exc()
break break
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
if not raw_posts_batch: if not raw_posts_batch:
if target_post_id and not processed_target_post_flag: 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}).") 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 +364,8 @@ def download_from_api(
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).") logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
else: else:
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).") 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 posts_batch_to_yield = raw_posts_batch
original_count = len(raw_posts_batch) original_count = len(raw_posts_batch)
@ -371,25 +375,17 @@ def download_from_api(
if skipped_count > 0: if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.") 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: 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) matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
if matching_post: if matching_post:
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).") logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
yield [matching_post] yield [matching_post]
processed_target_post_flag = True processed_target_post_flag = True
elif not target_post_id: elif not target_post_id:
# Downloading a creator feed
if posts_batch_to_yield: if posts_batch_to_yield:
# We found new posts on this page, yield them
yield posts_batch_to_yield yield posts_batch_to_yield
elif original_count > 0: 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...") 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: if processed_target_post_flag:
break break
@ -397,7 +393,6 @@ def download_from_api(
current_offset += page_size current_offset += page_size
current_page_num += 1 current_page_num += 1
time.sleep(0.6) 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()): 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).")

View File

@ -0,0 +1,174 @@
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"
def __init__(self, logger_func=print):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': '*/*',
})
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
}
resp = self.session.post(url, data=data, timeout=10)
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
while True:
try:
resp = self.session.get(url, params=params, timeout=20)
# Handle Token Expiration (401)
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")
# Handle Rate Limiting (429)
if resp.status_code == 429:
if retries < max_retries:
retry_after = resp.headers.get('Retry-After')
if retry_after:
sleep_time = int(retry_after) + 1
msg = f" [DeviantArt] ⚠️ Rate limit (Server says wait {sleep_time}s)."
else:
sleep_time = backoff_delay * (2 ** retries)
msg = f" [DeviantArt] ⚠️ Rate limit reached. Retrying in {sleep_time}s..."
# --- THREAD-SAFE LOGGING CHECK ---
should_log = False
with self.log_lock:
if sleep_time not in self.logged_waits:
self.logged_waits.add(sleep_time)
should_log = True
if should_log:
self.logger(msg)
time.sleep(sleep_time)
retries += 1
continue
else:
resp.raise_for_status()
resp.raise_for_status()
# Clear log history on success so we get warned again if limits return later
with self.log_lock:
if self.logged_waits:
self.logged_waits.clear()
return resp.json()
except requests.exceptions.RequestException as e:
if retries < max_retries:
# Using the lock here too to prevent connection error spam
should_log = False
with self.log_lock:
if "conn_error" not in self.logged_waits:
self.logged_waits.add("conn_error")
should_log = True
if should_log:
self.logger(f" [DeviantArt] Connection error: {e}. Retrying...")
time.sleep(2)
retries += 1
continue
raise e
def get_deviation_uuid(self, url):
"""Scrapes the deviation page to find the UUID."""
try:
resp = self.session.get(url, timeout=15)
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

View File

@ -56,12 +56,13 @@ from ..utils.text_utils import (
match_folders_from_title, match_folders_from_filename_enhanced match_folders_from_title, match_folders_from_filename_enhanced
) )
from ..config.constants import * from ..config.constants import *
from ..ui.dialogs.SinglePDF import create_individual_pdf
def robust_clean_name(name): def robust_clean_name(name):
"""A more robust function to remove illegal characters for filenames and folders.""" """A more robust function to remove illegal characters for filenames and folders."""
if not name: if not name:
return "" return ""
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\'\[\]]' illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\']'
cleaned_name = re.sub(illegal_chars_pattern, '', name) cleaned_name = re.sub(illegal_chars_pattern, '', name)
cleaned_name = cleaned_name.strip(' .') cleaned_name = cleaned_name.strip(' .')
@ -132,6 +133,8 @@ class PostProcessorWorker:
sfp_threshold=None, sfp_threshold=None,
handle_unknown_mode=False, handle_unknown_mode=False,
creator_name_cache=None, creator_name_cache=None,
add_info_in_pdf=False
): ):
self.post = post_data self.post = post_data
self.download_root = download_root self.download_root = download_root
@ -205,6 +208,10 @@ class PostProcessorWorker:
self.sfp_threshold = sfp_threshold self.sfp_threshold = sfp_threshold
self.handle_unknown_mode = handle_unknown_mode self.handle_unknown_mode = handle_unknown_mode
self.creator_name_cache = creator_name_cache self.creator_name_cache = creator_name_cache
#-- New assign --
self.add_info_in_pdf = add_info_in_pdf
#-- New assign --
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found.") self.logger("⚠️ Image compression disabled: Pillow library not found.")
@ -974,6 +981,92 @@ class PostProcessorWorker:
else: else:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure 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): def process(self):
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
try: try:
@ -1269,6 +1362,8 @@ class PostProcessorWorker:
if self.filter_mode == 'text_only' and not self.extract_links_only: if self.filter_mode == 'text_only' and not self.extract_links_only:
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})") self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
post_title_lower = post_title.lower() 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): 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: for skip_word in self.skip_words_list:
if skip_word.lower() in post_title_lower: if skip_word.lower() in post_title_lower:
@ -1287,6 +1382,7 @@ class PostProcessorWorker:
comments_data = [] comments_data = []
final_post_data = post_data final_post_data = post_data
# --- Content Fetching ---
if self.text_only_scope == 'content' and 'content' not in final_post_data: 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...") self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
parsed_url = urlparse(self.api_url_input) parsed_url = urlparse(self.api_url_input)
@ -1304,6 +1400,8 @@ class PostProcessorWorker:
api_domain = parsed_url.netloc 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) 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: if comments_data:
# For TXT/DOCX export, we format comments here.
# For PDF, we pass the raw list to the generator.
comment_texts = [] comment_texts = []
for comment in comments_data: for comment in comments_data:
user = comment.get('commenter_name', 'Unknown User') user = comment.get('commenter_name', 'Unknown User')
@ -1335,23 +1433,43 @@ class PostProcessorWorker:
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
return 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: 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 self.text_only_scope == 'comments':
if not comments_data: if not comments_data:
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
return result_tuple return result_tuple
content_data['comments'] = comments_data common_content_data['comments'] = comments_data
else: else:
if not cleaned_text.strip(): if not cleaned_text.strip():
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
return 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") temp_dir = os.path.join(self.app_base_dir, "appdata")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
@ -1359,7 +1477,7 @@ class PostProcessorWorker:
temp_filepath = os.path.join(temp_dir, temp_filename) temp_filepath = os.path.join(temp_dir, temp_filename)
try: try:
with open(temp_filepath, 'w', encoding='utf-8') as f: 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.") self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
result_tuple = (0, 0, [], [], [], None, temp_filepath) result_tuple = (0, 0, [], [], [], None, temp_filepath)
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
@ -1369,82 +1487,67 @@ class PostProcessorWorker:
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
return result_tuple return result_tuple
# --- Individual File Mode ---
else: else:
file_extension = self.text_export_format 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) final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename)
try: try:
os.makedirs(determined_post_save_path_for_history, exist_ok=True) 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 counter = 1
while os.path.exists(final_save_path): while os.path.exists(final_save_path):
final_save_path = f"{base}_{counter}{ext}" final_save_path = f"{base}_{counter}{ext}"
counter += 1 counter += 1
# --- PDF Generation ---
if file_extension == 'pdf': if file_extension == 'pdf':
if FPDF: # Font setup
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...") font_path = ""
pdf = PDF() if self.project_root_dir:
base_path = self.project_root_dir font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
font_path = ""
bold_font_path = "" # Add content specific fields for the generator
if self.text_only_scope == 'comments':
if base_path: common_content_data['comments_list_for_pdf'] = comments_data
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)
else: else:
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") common_content_data['content_text_for_pdf'] = cleaned_text
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) # 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': elif file_extension == 'docx':
if Document: if Document:
self.logger(f" Converting to DOCX...") self.logger(f" Converting to DOCX...")
document = Document() 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.add_paragraph(cleaned_text)
document.save(final_save_path) document.save(final_save_path)
else: else:
@ -1452,9 +1555,20 @@ class PostProcessorWorker:
final_save_path = os.path.splitext(final_save_path)[0] + ".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) 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: 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)}'") 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) result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
@ -1467,6 +1581,7 @@ class PostProcessorWorker:
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
return 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: 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.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)") self._emit_signal('missed_character_post', post_title, "Renaming Mode: No title match for character filter (Title/Both scope)")

View File

@ -0,0 +1,208 @@
import os
import time
import requests
import re
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, wait
from PyQt5.QtCore import QThread, pyqtSignal
from ...core.deviantart_client import DeviantArtClient
from ...utils.file_utils import clean_folder_name
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)
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None):
super().__init__(parent)
self.url = url
self.output_dir = output_dir
self.pause_event = pause_event
self.cancellation_event = cancellation_event
# --- PASS LOGGER TO CLIENT ---
# This ensures client logs go to the UI, not just the black console window
self.client = DeviantArtClient(logger_func=self.progress_signal.emit)
self.parent_app = parent
self.download_count = 0
self.skip_count = 0
# --- THREAD SETTINGS ---
self.max_threads = 10
def run(self):
self.progress_signal.emit("=" * 40)
self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}")
self.progress_signal.emit(f" Using {self.max_threads} parallel threads.")
try:
if not self.client.authenticate():
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)
with ThreadPoolExecutor(max_workers=self.max_threads) as executor:
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
futures = []
for deviation in results:
if self._check_pause_cancel(): break
future = executor.submit(self._process_deviation_task, deviation, base_folder)
futures.append(future)
wait(futures)
time.sleep(1)
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:
self.progress_signal.emit(f" ⚠️ Renaming failed ({e}), using default.")
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}")
with requests.get(file_url, stream=True, timeout=30) 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

View File

@ -24,7 +24,7 @@ from .rule34video_downloader_thread import Rule34VideoDownloadThread
from .saint2_downloader_thread import Saint2DownloadThread from .saint2_downloader_thread import Saint2DownloadThread
from .simp_city_downloader_thread import SimpCityDownloadThread from .simp_city_downloader_thread import SimpCityDownloadThread
from .toonily_downloader_thread import ToonilyDownloadThread 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): 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 # id1 contains the full URL or album ID from extract_post_info
return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app) 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 --- # --- Fallback ---
# If no specific handler matched based on service name or URL pattern, return None. # 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 # This signals main_window.py to use the generic BackendDownloadThread/PostProcessorWorker

View File

@ -254,6 +254,7 @@ class SimpCityDownloadThread(QThread):
self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked() 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_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
self.should_dl_mega = self.parent_app.simpcity_dl_mega_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_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_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) enriched_jobs = self._get_enriched_jobs(jobs)
if enriched_jobs: if enriched_jobs:
for job in enriched_jobs: for job in enriched_jobs:
if job['type'] == 'image': self.image_queue.put(job) if job['type'] == 'image':
else: self.service_queue.put(job) if self.should_dl_images: self.image_queue.put(job)
else: self.service_queue.put(job)
else: else:
base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/') 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 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 # 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.") self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
end_of_thread = True end_of_thread = True
else: else:
for job in enriched_jobs: for job in enriched_jobs:
self.processed_job_urls.add(job.get('url')) 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) else: self.service_queue.put(job)
page_fetch_successful = True; break page_fetch_successful = True; break
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
if e.response.status_code in [403, 404]: if e.response.status_code in [403, 404]:

View File

@ -7,7 +7,6 @@ from PyQt5.QtCore import Qt
class CustomFilenameDialog(QDialog): class CustomFilenameDialog(QDialog):
"""A dialog for creating a custom filename format string.""" """A dialog for creating a custom filename format string."""
# --- REPLACE THE 'AVAILABLE_KEYS' LIST WITH THIS DICTIONARY ---
DISPLAY_KEY_MAP = { DISPLAY_KEY_MAP = {
"PostID": "id", "PostID": "id",
"CreatorName": "creator_name", "CreatorName": "creator_name",
@ -19,7 +18,10 @@ class CustomFilenameDialog(QDialog):
"name": "name" "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) super().__init__(parent)
self.setWindowTitle("Custom Filename Format") self.setWindowTitle("Custom Filename Format")
self.setMinimumWidth(500) self.setMinimumWidth(500)
@ -31,9 +33,11 @@ class CustomFilenameDialog(QDialog):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
# --- Description --- # --- Description ---
description_label = QLabel( desc_text = "Create a filename format using placeholders. The date/time values will be automatically formatted."
"Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format." 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) description_label.setWordWrap(True)
layout.addWidget(description_label) layout.addWidget(description_label)
@ -42,15 +46,20 @@ class CustomFilenameDialog(QDialog):
layout.addWidget(format_label) layout.addWidget(format_label)
self.format_input = QLineEdit(self) self.format_input = QLineEdit(self)
self.format_input.setText(self.current_format) 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) layout.addWidget(self.format_input)
# --- Date 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) layout.addWidget(date_format_label)
self.date_format_input = QLineEdit(self) self.date_format_input = QLineEdit(self)
self.date_format_input.setText(self.current_date_format) 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) layout.addWidget(self.date_format_input)
# --- Available Keys Display --- # --- Available Keys Display ---
@ -62,7 +71,20 @@ class CustomFilenameDialog(QDialog):
for display_key, internal_key in self.DISPLAY_KEY_MAP.items(): for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
key_button = QPushButton(f"{{{display_key}}}") 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)) key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
keys_layout.addWidget(key_button) keys_layout.addWidget(key_button)
keys_layout.addStretch() keys_layout.addStretch()
@ -81,9 +103,7 @@ class CustomFilenameDialog(QDialog):
self.format_input.setFocus() self.format_input.setFocus()
def get_format_string(self): def get_format_string(self):
"""Returns the final format string from the input field."""
return self.format_input.text().strip() return self.format_input.text().strip()
def get_date_format_string(self): 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()

View File

@ -156,6 +156,9 @@ class EmptyPopupDialog (QDialog ):
# --- MODIFIED: Store a list of profiles now --- # --- MODIFIED: Store a list of profiles now ---
self.update_profiles_list = None 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) --- # --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
self.update_profile_data = None self.update_profile_data = None
self.update_creator_name = None self.update_creator_name = None
@ -341,6 +344,9 @@ class EmptyPopupDialog (QDialog ):
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
# --- MODIFIED: Get a list of profiles now --- # --- MODIFIED: Get a list of profiles now ---
selected_profiles = dialog.get_selected_profiles() 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: if selected_profiles:
try: try:
# --- MODIFIED: Store the list --- # --- MODIFIED: Store the list ---
@ -1052,4 +1058,4 @@ class EmptyPopupDialog (QDialog ):
else : else :
if unique_key in self .globally_selected_creators : if unique_key in self .globally_selected_creators :
del self .globally_selected_creators [unique_key ] 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 ))

View File

@ -11,17 +11,16 @@ class MoreOptionsDialog(QDialog):
SCOPE_CONTENT = "content" SCOPE_CONTENT = "content"
SCOPE_COMMENTS = "comments" 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) super().__init__(parent)
self.parent_app = parent self.parent_app = parent
self.setWindowTitle("More Options") self.setWindowTitle("More Options")
self.setMinimumWidth(350) self.setMinimumWidth(350)
# ... (Layout and other widgets remain the same) ...
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.description_label = QLabel("Please choose the scope for the action:") self.description_label = QLabel("Please choose the scope for the action:")
layout.addWidget(self.description_label) layout.addWidget(self.description_label)
self.radio_button_group = QButtonGroup(self) self.radio_button_group = QButtonGroup(self)
self.radio_content = QRadioButton("Description/Content") self.radio_content = QRadioButton("Description/Content")
self.radio_comments = QRadioButton("Comments") self.radio_comments = QRadioButton("Comments")
@ -50,14 +49,20 @@ class MoreOptionsDialog(QDialog):
export_layout.addStretch() export_layout.addStretch()
layout.addLayout(export_layout) layout.addLayout(export_layout)
# --- UPDATED: Single PDF Checkbox --- # --- Single PDF Checkbox ---
self.single_pdf_checkbox = QCheckBox("Single PDF") 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.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
self.single_pdf_checkbox.setChecked(single_pdf_checked) self.single_pdf_checkbox.setChecked(single_pdf_checked)
layout.addWidget(self.single_pdf_checkbox) layout.addWidget(self.single_pdf_checkbox)
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state) # --- NEW: Add Info Checkbox ---
self.update_single_pdf_checkbox_state(self.format_combo.currentText()) 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 = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept) self.button_box.accepted.connect(self.accept)
@ -65,12 +70,18 @@ class MoreOptionsDialog(QDialog):
layout.addWidget(self.button_box) layout.addWidget(self.button_box)
self.setLayout(layout) self.setLayout(layout)
self._apply_theme() 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") is_pdf = (text.upper() == "PDF")
self.single_pdf_checkbox.setEnabled(is_pdf) self.single_pdf_checkbox.setEnabled(is_pdf)
self.add_info_checkbox.setEnabled(is_pdf)
if not is_pdf: if not is_pdf:
self.single_pdf_checkbox.setChecked(False) 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): def get_selected_scope(self):
if self.radio_comments.isChecked(): if self.radio_comments.isChecked():
@ -84,13 +95,14 @@ class MoreOptionsDialog(QDialog):
"""Returns the state of the Single PDF checkbox.""" """Returns the state of the Single PDF checkbox."""
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled() 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): def _apply_theme(self):
"""Applies the current theme from the parent application.""" """Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark": if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1) scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale)) self.setStyleSheet(get_dark_theme(scale))
else: else:
# Explicitly set a blank stylesheet for light mode self.setStyleSheet("")
self.setStyleSheet("")

View File

@ -4,24 +4,22 @@ try:
from fpdf import FPDF from fpdf import FPDF
FPDF_AVAILABLE = True FPDF_AVAILABLE = True
# --- FIX: Move the class definition inside the try block ---
class PDF(FPDF): class PDF(FPDF):
"""Custom PDF class to handle headers and footers.""" """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): def header(self):
pass pass
def footer(self): def footer(self):
self.set_y(-15) self.set_y(-15)
if self.font_family: self.set_font(self.font_family_main, '', 8)
self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
except ImportError: except ImportError:
FPDF_AVAILABLE = False FPDF_AVAILABLE = False
# If the import fails, FPDF and PDF will not be defined,
# but the program won't crash here.
FPDF = None FPDF = None
PDF = None PDF = None
@ -31,12 +29,169 @@ def strip_html_tags(text):
clean = re.compile('<.*?>') clean = re.compile('<.*?>')
return re.sub(clean, '', text) 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: 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 return False
if not posts_data: if not posts_data:
@ -44,34 +199,21 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return False return False
pdf = PDF() 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...") logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
for i, post in enumerate(posts_data): for i, post in enumerate(posts_data):
if i > 0: if add_info_page:
# This ensures every post after the first gets its own 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.add_page()
pdf.set_font(default_font_family, 'B', 16) if not add_info_page:
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L') pdf.set_font(font_family, 'B', 16)
pdf.ln(5) 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']: if 'comments' in post and post['comments']:
comments_list = 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') timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', '')) 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: ") pdf.write(8, "Comment by: ")
if user is not None: 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.write(8, str(user))
pdf.set_font(default_font_family, '', 10) pdf.set_font(font_family, '', 10)
pdf.write(8, f" on {timestamp}") pdf.write(8, f" on {timestamp}")
pdf.ln(10) 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) pdf.multi_cell(w=0, h=7, txt=body)
if comment_index < len(comments_list) - 1: 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.cell(w=0, h=0, border='T')
pdf.ln(3) pdf.ln(3)
elif 'content' in post: 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')) pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
try: try:
@ -107,4 +249,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return True return True
except Exception as e: except Exception as e:
logger(f"❌ A critical error occurred while saving the final PDF: {e}") logger(f"❌ A critical error occurred while saving the final PDF: {e}")
return False return False

View File

@ -7,7 +7,7 @@ import sys
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QMessageBox, QAbstractItemView, QLabel QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox
) )
# --- Local Application Imports --- # --- Local Application Imports ---
@ -26,6 +26,11 @@ class UpdateCheckDialog(QDialog):
self.parent_app = parent_app_ref self.parent_app = parent_app_ref
self.user_data_path = user_data_path self.user_data_path = user_data_path
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...} self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
self._default_checkbox_tooltip = (
"If checked, the settings from the selected profile will be loaded into the main window.\n"
"You can then modify them. When you start the download, the new settings will be saved to the profile."
)
self._init_ui() self._init_ui()
self._load_profiles() self._load_profiles()
@ -56,8 +61,16 @@ class UpdateCheckDialog(QDialog):
self.list_widget = QListWidget() self.list_widget = QListWidget()
# No selection mode, we only care about checkboxes # No selection mode, we only care about checkboxes
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection) 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) layout.addWidget(self.list_widget)
# --- NEW: Checkbox to Load Settings ---
self.load_settings_checkbox = QCheckBox("Load profile settings into UI (Edit Settings)")
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
layout.addWidget(self.load_settings_checkbox)
# -------------------------------------
# --- All Buttons in One Horizontal Layout --- # --- All Buttons in One Horizontal Layout ---
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
button_layout.setSpacing(6) # small even spacing between all buttons button_layout.setSpacing(6) # small even spacing between all buttons
@ -97,6 +110,7 @@ class UpdateCheckDialog(QDialog):
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All")) self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected")) self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close")) self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
self.load_settings_checkbox.setText(self._tr("update_check_load_settings_checkbox", "Load profile settings into UI (Edit Settings)"))
def _load_profiles(self): def _load_profiles(self):
"""Loads all .json files from the creator_profiles directory as checkable items.""" """Loads all .json files from the creator_profiles directory as checkable items."""
@ -144,16 +158,44 @@ class UpdateCheckDialog(QDialog):
self.check_button.setEnabled(False) self.check_button.setEnabled(False)
self.select_all_button.setEnabled(False) self.select_all_button.setEnabled(False)
self.deselect_all_button.setEnabled(False) self.deselect_all_button.setEnabled(False)
self.load_settings_checkbox.setEnabled(False)
def _toggle_all_checkboxes(self): def _toggle_all_checkboxes(self):
"""Handles Select All and Deselect All button clicks.""" """Handles Select All and Deselect All button clicks."""
sender = self.sender() sender = self.sender()
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
# Block signals to prevent triggering _handle_item_changed repeatedly
self.list_widget.blockSignals(True)
for i in range(self.list_widget.count()): for i in range(self.list_widget.count()):
item = self.list_widget.item(i) item = self.list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable: if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(check_state) item.setCheckState(check_state)
self.list_widget.blockSignals(False)
# Manually trigger the update once after batch change
self._handle_item_changed(None)
def _handle_item_changed(self, item):
"""
Monitors how many items are checked.
If more than 1 item is checked, disable the 'Load Settings' checkbox.
"""
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.load_settings_checkbox.setChecked(False)
self.load_settings_checkbox.setEnabled(False)
self.load_settings_checkbox.setToolTip(
self._tr("update_check_multi_selection_warning",
"Editing settings is disabled when multiple profiles are selected.")
)
else:
self.load_settings_checkbox.setEnabled(True)
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
def on_check_selected(self): def on_check_selected(self):
"""Handles the 'Check Selected' button click.""" """Handles the 'Check Selected' button click."""
@ -176,4 +218,9 @@ class UpdateCheckDialog(QDialog):
def get_selected_profiles(self): def get_selected_profiles(self):
"""Returns the list of profile data selected by the user.""" """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 'Load settings into UI' checkbox is checked."""
# Only return True if it's enabled and checked (double safety)
return self.load_settings_checkbox.isEnabled() and self.load_settings_checkbox.isChecked()

View File

@ -163,7 +163,8 @@ class DownloaderApp (QWidget ):
self.is_ready_to_download_batch_update = False self.is_ready_to_download_batch_update = False
self.is_finishing = False self.is_finishing = False
self.finish_lock = threading.Lock() self.finish_lock = threading.Lock()
self.add_info_in_pdf_setting = False
saved_res = self.settings.value(RESOLUTION_KEY, "Auto") saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
if saved_res != "Auto": if saved_res != "Auto":
try: try:
@ -339,7 +340,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None self.download_location_label_widget = None
self.remove_from_filename_label_widget = None self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v6.7.0") self.setWindowTitle("Kemono Downloader v7.8.0")
setup_ui(self) setup_ui(self)
self._connect_signals() self._connect_signals()
if hasattr(self, 'character_input'): if hasattr(self, 'character_input'):
@ -656,6 +657,7 @@ class DownloaderApp (QWidget ):
settings['more_filter_scope'] = self.more_filter_scope settings['more_filter_scope'] = self.more_filter_scope
settings['text_export_format'] = self.text_export_format settings['text_export_format'] = self.text_export_format
settings['single_pdf_setting'] = self.single_pdf_setting settings['single_pdf_setting'] = self.single_pdf_setting
settings['add_info_in_pdf'] = self.add_info_in_pdf_setting # Save to settings dict
settings['keep_duplicates_mode'] = self.keep_duplicates_mode settings['keep_duplicates_mode'] = self.keep_duplicates_mode
settings['keep_duplicates_limit'] = self.keep_duplicates_limit settings['keep_duplicates_limit'] = self.keep_duplicates_limit
@ -936,7 +938,7 @@ class DownloaderApp (QWidget ):
domain_override = download_commands.get('domain_override') domain_override = download_commands.get('domain_override')
args_template = self.last_start_download_args args_template = self.last_start_download_args
args_template['add_info_in_pdf'] = self.add_info_in_pdf_setting
args_template['filter_character_list'] = parsed_filters args_template['filter_character_list'] = parsed_filters
args_template['domain_override'] = domain_override args_template['domain_override'] = domain_override
@ -2938,33 +2940,59 @@ class DownloaderApp (QWidget ):
return True return True
def _handle_more_options_toggled(self, button, checked): def _handle_more_options_toggled(self, button, checked):
"""Shows the MoreOptionsDialog when the 'More' radio button is selected.""" """
Handles the toggle event for the 'More' radio button.
Opens the configuration dialog when checked and resets state when unchecked.
"""
# Case 1: The "More" button was just selected
if button == self.radio_more and checked: if button == self.radio_more and checked:
# Determine initial values for the dialog based on current state or defaults
current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT
current_format = self.text_export_format or 'pdf' current_format = self.text_export_format or 'pdf'
# Initialize the dialog with the 'add_info_checked' parameter
dialog = MoreOptionsDialog( dialog = MoreOptionsDialog(
self, self,
current_scope=current_scope, current_scope=current_scope,
current_format=current_format, current_format=current_format,
single_pdf_checked=self.single_pdf_setting single_pdf_checked=self.single_pdf_setting,
add_info_checked=self.add_info_in_pdf_setting # <--- Pass current setting
) )
# Show the dialog and wait for user action
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
# --- User clicked OK: Update settings ---
self.more_filter_scope = dialog.get_selected_scope() self.more_filter_scope = dialog.get_selected_scope()
self.text_export_format = dialog.get_selected_format() self.text_export_format = dialog.get_selected_format()
self.single_pdf_setting = dialog.get_single_pdf_state() self.single_pdf_setting = dialog.get_single_pdf_state()
self.add_info_in_pdf_setting = dialog.get_add_info_state() # <--- Update setting
# --- Update Button Text ---
is_any_pdf_mode = (self.text_export_format == 'pdf') is_any_pdf_mode = (self.text_export_format == 'pdf')
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description" scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
format_display = f" ({self.text_export_format.upper()})"
# Construct a descriptive label (e.g., "Description (PDF [Single+Info])")
format_extras = []
if self.single_pdf_setting: if self.single_pdf_setting:
format_display = " (Single PDF)" format_extras.append("Single")
if is_any_pdf_mode and self.add_info_in_pdf_setting:
format_extras.append("Info")
extra_str = f" [{'+'.join(format_extras)}]" if format_extras else ""
format_display = f" ({self.text_export_format.upper()}{extra_str})"
self.radio_more.setText(f"{scope_text}{format_display}") self.radio_more.setText(f"{scope_text}{format_display}")
# --- Handle Option Conflicts (Multithreading) ---
# PDF generation requires the main thread or careful management, so we disable multithreading
if hasattr(self, 'use_multithreading_checkbox'): if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode) self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode)
if is_any_pdf_mode: if is_any_pdf_mode:
self.use_multithreading_checkbox.setChecked(False) self.use_multithreading_checkbox.setChecked(False)
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
# --- Handle Option Conflicts (Subfolders) ---
# Single PDF mode consolidates files, so subfolders are often disabled
if hasattr(self, 'use_subfolders_checkbox'): if hasattr(self, 'use_subfolders_checkbox'):
self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting) self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting)
if self.single_pdf_setting: if self.single_pdf_setting:
@ -2975,23 +3003,39 @@ class DownloaderApp (QWidget ):
if self.single_pdf_setting: if self.single_pdf_setting:
self.use_subfolder_per_post_checkbox.setChecked(False) self.use_subfolder_per_post_checkbox.setChecked(False)
self.log_signal.emit(f" 'More' filter scope set to: {scope_text}, Format: {self.text_export_format.upper()}") # --- Logging ---
self.log_signal.emit(f" Single PDF setting: {'Enabled' if self.single_pdf_setting else 'Disabled'}") self.log_signal.emit(f" 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
if is_any_pdf_mode: if is_any_pdf_mode:
self.log_signal.emit(" Multithreading automatically disabled for PDF export.") status_single = "Enabled" if self.single_pdf_setting else "Disabled"
status_info = "Enabled" if self.add_info_in_pdf_setting else "Disabled"
self.log_signal.emit(f" ↳ PDF Options: Single PDF={status_single}, Add Info Page={status_info}")
self.log_signal.emit(" ↳ Multithreading disabled for PDF export.")
else: else:
# --- User clicked Cancel: Revert to default ---
self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.") self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.")
self.radio_all.setChecked(True) if hasattr(self, 'radio_all'):
self.radio_all.setChecked(True)
# Case 2: Switched AWAY from the "More" button (e.g., clicked 'Images' or 'All')
elif button != self.radio_more and checked: elif button != self.radio_more and checked:
self.radio_more.setText("More") self.radio_more.setText("More")
self.more_filter_scope = None self.more_filter_scope = None
self.single_pdf_setting = False self.single_pdf_setting = False
self.add_info_in_pdf_setting = False # Reset setting
# Restore enabled states for options that PDF mode might have disabled
if hasattr(self, 'use_multithreading_checkbox'): if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True) self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode() self._update_multithreading_for_date_mode() # Re-check manga logic
if hasattr(self, 'use_subfolders_checkbox'): if hasattr(self, 'use_subfolders_checkbox'):
self.use_subfolders_checkbox.setEnabled(True) self.use_subfolders_checkbox.setEnabled(True)
if hasattr(self, 'use_subfolder_per_post_checkbox'):
# Re-enable based on current subfolder checkbox state
self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked())
def delete_selected_character (self ): def delete_selected_character (self ):
global KNOWN_NAMES global KNOWN_NAMES
selected_items =self .character_list .selectedItems () selected_items =self .character_list .selectedItems ()
@ -3134,55 +3178,66 @@ class DownloaderApp (QWidget ):
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Renaming Mode is active for a creator feed).") self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Renaming Mode is active for a creator feed).")
def _toggle_manga_filename_style (self ): def _toggle_manga_filename_style(self):
url_text = self.link_input.text().strip() if self.link_input else "" url_text = self.link_input.text().strip() if self.link_input else ""
_, _, post_id = extract_post_info(url_text) service, _, post_id = extract_post_info(url_text)
is_single_post = bool(post_id)
print(f"DEBUG: Toggle Style - URL: {url_text}, Service Detected: {service}")
if service == 'deviantart':
self.log_signal.emit(" DeviantArt mode allows only Custom Renaming format.")
self.manga_filename_style = STYLE_CUSTOM
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.sync()
self._update_manga_filename_style_button_text()
self._show_custom_rename_dialog()
return
is_single_post = bool(post_id)
current_style = self.manga_filename_style current_style = self.manga_filename_style
new_style = "" new_style = ""
if is_single_post: if is_single_post:
# Cycle through a limited set of styles suitable for single posts # ... (Cycle logic for single posts) ...
if current_style == STYLE_POST_TITLE: if current_style == STYLE_POST_TITLE:
new_style = STYLE_DATE_POST_TITLE new_style = STYLE_DATE_POST_TITLE
elif current_style == STYLE_DATE_POST_TITLE: elif current_style == STYLE_DATE_POST_TITLE:
new_style = STYLE_ORIGINAL_NAME new_style = STYLE_ORIGINAL_NAME
elif current_style == STYLE_ORIGINAL_NAME: elif current_style == STYLE_ORIGINAL_NAME:
new_style = STYLE_POST_ID new_style = STYLE_POST_ID
elif current_style == STYLE_POST_ID: elif current_style == STYLE_POST_ID:
new_style = STYLE_CUSTOM new_style = STYLE_CUSTOM
elif current_style == STYLE_CUSTOM: elif current_style == STYLE_CUSTOM:
new_style = STYLE_POST_TITLE new_style = STYLE_POST_TITLE
else: # Fallback for any other style else:
new_style = STYLE_POST_TITLE new_style = STYLE_POST_TITLE
else: else:
# Original cycling logic for creator feeds # ... (Cycle logic for creators) ...
if current_style ==STYLE_POST_TITLE : if current_style == STYLE_POST_TITLE:
new_style =STYLE_ORIGINAL_NAME new_style = STYLE_ORIGINAL_NAME
elif current_style ==STYLE_ORIGINAL_NAME : elif current_style == STYLE_ORIGINAL_NAME:
new_style =STYLE_DATE_POST_TITLE new_style = STYLE_DATE_POST_TITLE
elif current_style ==STYLE_DATE_POST_TITLE : elif current_style == STYLE_DATE_POST_TITLE:
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING new_style = STYLE_POST_TITLE_GLOBAL_NUMBERING
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : elif current_style == STYLE_POST_TITLE_GLOBAL_NUMBERING:
new_style =STYLE_DATE_BASED new_style = STYLE_DATE_BASED
elif current_style ==STYLE_DATE_BASED : elif current_style == STYLE_DATE_BASED:
new_style =STYLE_POST_ID new_style = STYLE_POST_ID
elif current_style ==STYLE_POST_ID: elif current_style == STYLE_POST_ID:
new_style =STYLE_CUSTOM # <-- CHANGE THIS new_style = STYLE_CUSTOM
elif current_style == STYLE_CUSTOM: # <-- ADD THIS elif current_style == STYLE_CUSTOM:
new_style = STYLE_POST_TITLE # <-- ADD THIS new_style = STYLE_POST_TITLE
else : else:
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').") new_style = STYLE_POST_TITLE
new_style =STYLE_POST_TITLE
self .manga_filename_style =new_style
self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
self .settings .sync ()
self ._update_manga_filename_style_button_text ()
self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
self .log_signal .emit (f" Manga filename style changed to: '{self .manga_filename_style }'")
self.manga_filename_style = new_style
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.sync()
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
def _handle_favorite_mode_toggle (self ,checked ): def _handle_favorite_mode_toggle (self ,checked ):
if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack : if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
return return
@ -3238,7 +3293,6 @@ class DownloaderApp (QWidget ):
is_discord_url = (service == 'discord') is_discord_url = (service == 'discord')
if is_discord_url: if is_discord_url:
# When a discord URL is detected, disable incompatible options
if self.manga_mode_checkbox: if self.manga_mode_checkbox:
self.manga_mode_checkbox.setEnabled(False) self.manga_mode_checkbox.setEnabled(False)
self.manga_mode_checkbox.setChecked(False) self.manga_mode_checkbox.setChecked(False)
@ -3247,13 +3301,11 @@ class DownloaderApp (QWidget ):
if self.to_label: self.to_label.setEnabled(False) if self.to_label: self.to_label.setEnabled(False)
if self.end_page_input: self.end_page_input.setEnabled(False) if self.end_page_input: self.end_page_input.setEnabled(False)
checked = False # Force manga mode off checked = False # Force manga mode off
# --- END: NEW DISCORD UI LOGIC ---
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked () is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked ()
is_only_audio_mode =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked () is_only_audio_mode =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
# The rest of the original function continues from here...
_ ,_ ,post_id =extract_post_info (url_text ) _ ,_ ,post_id =extract_post_info (url_text )
is_creator_feed =not post_id if url_text else False is_creator_feed =not post_id if url_text else False
@ -3280,7 +3332,6 @@ class DownloaderApp (QWidget ):
if self .manga_rename_toggle_button : if self .manga_rename_toggle_button :
self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )) self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
# --- MODIFIED: Added check for is_discord_url ---
if not is_discord_url: if not is_discord_url:
self .update_page_range_enabled_state () self .update_page_range_enabled_state ()
@ -3318,7 +3369,20 @@ class DownloaderApp (QWidget ):
def _show_custom_rename_dialog(self): def _show_custom_rename_dialog(self):
"""Shows the dialog to edit the custom manga filename format.""" """Shows the dialog to edit the custom manga filename format."""
dialog = CustomFilenameDialog(self.custom_manga_filename_format, self.manga_custom_date_format, self)
# 1. Detect if the current URL is DeviantArt
url_text = self.link_input.text().strip() if self.link_input else ""
service, _, _ = extract_post_info(url_text)
is_deviantart = (service == 'deviantart')
# 2. Pass the 'is_deviantart' flag to the dialog
dialog = CustomFilenameDialog(
self.custom_manga_filename_format,
self.manga_custom_date_format,
self,
is_deviantart=is_deviantart # <--- THIS WAS MISSING
)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
self.custom_manga_filename_format = dialog.get_format_string() self.custom_manga_filename_format = dialog.get_format_string()
self.manga_custom_date_format = dialog.get_date_format_string() self.manga_custom_date_format = dialog.get_date_format_string()
@ -3328,7 +3392,6 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(f" Custom date format set to: '{self.manga_custom_date_format}'") self.log_signal.emit(f" Custom date format set to: '{self.manga_custom_date_format}'")
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
def update_multithreading_label (self ,text ): def update_multithreading_label (self ,text ):
if self .use_multithreading_checkbox .isChecked (): if self .use_multithreading_checkbox .isChecked ():
base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading") base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading")
@ -3445,7 +3508,7 @@ class DownloaderApp (QWidget ):
def _update_contextual_ui_elements(self, text=""): def _update_contextual_ui_elements(self, text=""):
"""Shows or hides UI elements based on the URL, like the Discord scope button.""" """Shows or hides UI elements based on the URL, like the Discord scope button."""
if 'allporncomic.com' in text.lower() and not hasattr(self, 'allcomic_warning_shown'): if 'allporncomic.com' in text.lower() and not hasattr(self, 'allcomic_warning_shown'):
self.allcomic_warning_shown = False self.allcomic_warning_shown = False
if 'allporncomic.com' in text.lower() and not self.allcomic_warning_shown: if 'allporncomic.com' in text.lower() and not self.allcomic_warning_shown:
@ -3468,13 +3531,53 @@ class DownloaderApp (QWidget ):
url_text = self.link_input.text().strip() url_text = self.link_input.text().strip()
service, _, _ = extract_post_info(url_text) service, _, _ = extract_post_info(url_text)
# --- DEFINE VARIABLES FIRST ---
is_deviantart = (service == 'deviantart')
is_simpcity = (service == 'simpcity') is_simpcity = (service == 'simpcity')
is_any_discord_url = (service == 'discord')
is_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text
is_erome = 'erome.com' in url_text
is_fap_nation = 'fap-nation.com' in url_text or 'fap-nation.org' in url_text
# --- UPDATE SPECIALIZED LIST ---
is_specialized = service in ['bunkr', 'nhentai', 'hentai2read', 'simpcity', 'deviantart'] or is_saint2 or is_erome
# We disable standard UI elements for these services to prevent conflicts
is_specialized_for_disabling = service in ['bunkr', 'nhentai', 'hentai2read', 'deviantart'] or is_saint2 or is_erome
self._set_ui_for_specialized_downloader(is_specialized_for_disabling)
if hasattr(self, 'advanced_settings_widget'): if hasattr(self, 'advanced_settings_widget'):
self.advanced_settings_widget.setVisible(not is_simpcity) self.advanced_settings_widget.setVisible(not is_simpcity)
if hasattr(self, 'simpcity_settings_widget'): if hasattr(self, 'simpcity_settings_widget'):
self.simpcity_settings_widget.setVisible(is_simpcity) self.simpcity_settings_widget.setVisible(is_simpcity)
is_any_discord_url = (service == 'discord') if is_deviantart:
from ..config.constants import STYLE_CUSTOM
if self.manga_filename_style != STYLE_CUSTOM:
self.log_signal.emit(" DeviantArt mode allows only Custom Renaming format. Switched to Custom.")
if self.manga_mode_checkbox:
self.manga_mode_checkbox.setEnabled(True)
self.manga_mode_checkbox.setToolTip("Enable Custom Renaming for DeviantArt")
self.manga_filename_style = STYLE_CUSTOM
self._update_manga_filename_style_button_text()
if self.manga_rename_toggle_button:
self.manga_rename_toggle_button.setEnabled(False)
self.manga_rename_toggle_button.setToolTip("DeviantArt only supports Custom Renaming.")
if self.manga_mode_checkbox and self.manga_mode_checkbox.isChecked():
if self.manga_rename_toggle_button:
self.manga_rename_toggle_button.setVisible(True)
if hasattr(self, 'custom_rename_dialog_button'):
self.custom_rename_dialog_button.setVisible(True)
else:
if self.manga_rename_toggle_button:
self.manga_rename_toggle_button.setEnabled(True)
is_official_discord_url = 'discord.com' in url_text and is_any_discord_url is_official_discord_url = 'discord.com' in url_text and is_any_discord_url
if is_official_discord_url: if is_official_discord_url:
@ -3489,15 +3592,6 @@ class DownloaderApp (QWidget ):
self.remove_from_filename_input.setPlaceholderText(self._tr("remove_from_filename_input_placeholder_text", "e.g., patreon, HD")) self.remove_from_filename_input.setPlaceholderText(self._tr("remove_from_filename_input_placeholder_text", "e.g., patreon, HD"))
self.remove_from_filename_input.setEchoMode(QLineEdit.Normal) self.remove_from_filename_input.setEchoMode(QLineEdit.Normal)
is_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text
is_erome = 'erome.com' in url_text
is_fap_nation = 'fap-nation.com' in url_text or 'fap-nation.org' in url_text
is_specialized = service in ['bunkr', 'nhentai', 'hentai2read', 'simpcity'] or is_saint2 or is_erome
is_specialized_for_disabling = service in ['bunkr', 'nhentai', 'hentai2read'] or is_saint2 or is_erome
self._set_ui_for_specialized_downloader(is_specialized_for_disabling)
self.discord_scope_toggle_button.setVisible(is_any_discord_url) self.discord_scope_toggle_button.setVisible(is_any_discord_url)
if hasattr(self, 'discord_message_limit_input'): if hasattr(self, 'discord_message_limit_input'):
self.discord_message_limit_input.setVisible(is_official_discord_url) self.discord_message_limit_input.setVisible(is_official_discord_url)
@ -3533,7 +3627,12 @@ class DownloaderApp (QWidget ):
return get_theme_stylesheet(actual_scale) return get_theme_stylesheet(actual_scale)
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None): def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, MAX_FILE_THREADS_PER_POST_OR_WORKER
from ..utils.file_utils import clean_folder_name, KNOWN_NAMES
from ..config.constants import FOLDER_NAME_STOP_WORDS
if not is_restore and not is_continuation: if not is_restore and not is_continuation:
if self.main_log_output: self.main_log_output.clear() if self.main_log_output: self.main_log_output.clear()
if self.external_log_output: self.external_log_output.clear() if self.external_log_output: self.external_log_output.clear()
@ -3547,7 +3646,29 @@ class DownloaderApp (QWidget ):
if not direct_api_url: if not direct_api_url:
api_url_text = self.link_input.text().strip().lower() api_url_text = self.link_input.text().strip().lower()
batch_handlers = { batch_handlers = {
'kemono.cr': {
'name': 'Kemono Batch',
'txt_file': 'kemono.txt',
'url_regex': r'https?://(?:www\.)?kemono\.(?:su|party|cr)/[^/\s]+/user/\d+(?:/post/\d+)?/?'
},
'kemono.su': {
'name': 'Kemono Batch',
'txt_file': 'kemono.txt',
'url_regex': r'https?://(?:www\.)?kemono\.(?:su|party|cr)/[^/\s]+/user/\d+(?:/post/\d+)?/?'
},
'coomer.st': {
'name': 'Coomer Batch',
'txt_file': 'coomer.txt',
'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?'
},
'coomer.su': {
'name': 'Coomer Batch',
'txt_file': 'coomer.txt',
'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?'
},
'allporncomic.com': { 'allporncomic.com': {
'name': 'AllPornComic', 'name': 'AllPornComic',
'txt_file': 'allporncomic.txt', 'txt_file': 'allporncomic.txt',
@ -3621,21 +3742,75 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.") self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
self.favorite_download_queue.clear() self.favorite_download_queue.clear()
self_managed_folder_sites = [
'allporncomic.com', 'allcomic.com',
'fap-nation.com', 'fap-nation.org',
'toonily.com', 'toonily.me',
'hentai2read.com',
'saint2.su', 'saint2.pk',
'imgur.com', 'bunkr.'
]
for url in urls_to_download: for url in urls_to_download:
# 1. Check if this site manages its own folders
is_self_managed = any(site in url.lower() for site in self_managed_folder_sites)
# 2. Extract info
service_extracted, user_id_extracted, pid = extract_post_info(url)
# Default values
force_folder = False
folder_name_to_use = "Unknown_Batch_Item"
item_type = 'post' # Default to post/comic
if is_self_managed:
# Case A: Self-Managed Sites (AllPornComic, FapNation, etc.)
# We do NOT force a folder here. We let the specialized downloader create
# the correct folder (e.g. "Main Title") inside the root directory.
force_folder = False
folder_name_to_use = "Auto-Detected" # Placeholder, won't be used for root folder
item_type = 'post'
elif service_extracted:
# Case B: Standard ID-based sites (Kemono, Coomer, Nhentai, etc.)
# We MUST force a folder (Artist Name) to keep them organized.
item_type = 'post' if pid else 'artist'
force_folder = True
cache_key = (service_extracted.lower(), str(user_id_extracted))
creator_name = self.creator_name_cache.get(cache_key)
if creator_name:
folder_name_to_use = clean_folder_name(creator_name)
else:
folder_name_to_use = f"{service_extracted}_{user_id_extracted}"
else:
# Case C: Unknown sites (Fallback)
# We force a folder based on the URL slug to ensure it doesn't clutter the root.
try:
path_parts = urlparse(url).path.strip('/').split('/')
folder_candidate = path_parts[-1] if path_parts else "Unknown_Item"
folder_name_to_use = clean_folder_name(folder_candidate)
force_folder = True
except Exception:
folder_name_to_use = "Unknown_Batch_Item"
force_folder = True
# 3. Add to queue
self.favorite_download_queue.append({ self.favorite_download_queue.append({
'url': url, 'url': url,
'name': f"{name} link from batch", 'name': f"{name} batch: {folder_name_to_use}",
'type': 'post' 'name_for_folder': folder_name_to_use,
'type': item_type,
'force_artist_folder': force_folder # <--- Only True for Kemono/Coomer/Unknown
}) })
if not self.is_processing_favorites_queue: if not self.is_processing_favorites_queue:
self._process_next_favorite_download() self._process_next_favorite_download()
return True # Stop further execution of start_download return True # Stop further execution of start_download
from ..utils.file_utils import clean_folder_name
from ..config.constants import FOLDER_NAME_STOP_WORDS
if self.is_ready_to_download_fetched: if self.is_ready_to_download_fetched:
self._start_download_of_fetched_posts() self._start_download_of_fetched_posts()
return True return True
@ -3649,7 +3824,6 @@ class DownloaderApp (QWidget ):
self.is_finishing = False self.is_finishing = False
self.downloaded_hash_counts.clear() self.downloaded_hash_counts.clear()
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, MAX_FILE_THREADS_PER_POST_OR_WORKER
if not is_restore and not is_continuation: if not is_restore and not is_continuation:
self.permanently_failed_files_for_dialog.clear() self.permanently_failed_files_for_dialog.clear()
@ -4321,7 +4495,8 @@ class DownloaderApp (QWidget ):
'start_offset': start_offset_for_restore, 'start_offset': start_offset_for_restore,
'fetch_first': fetch_first_enabled, 'fetch_first': fetch_first_enabled,
'sfp_threshold': download_commands.get('sfp_threshold'), 'sfp_threshold': download_commands.get('sfp_threshold'),
'handle_unknown_mode': handle_unknown_command 'handle_unknown_mode': handle_unknown_command,
'add_info_in_pdf': self.add_info_in_pdf_setting,
} }
args_template['override_output_dir'] = override_output_dir args_template['override_output_dir'] = override_output_dir
@ -4734,6 +4909,7 @@ class DownloaderApp (QWidget ):
'use_cookie': self.use_cookie_checkbox.isChecked(), 'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(), 'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath, 'selected_cookie_file': self.selected_cookie_filepath,
'add_info_in_pdf': self.add_info_in_pdf_setting,
} }
# 2. Define DEFAULTS for all settings that *should* be in the profile. # 2. Define DEFAULTS for all settings that *should* be in the profile.
@ -4776,6 +4952,7 @@ class DownloaderApp (QWidget ):
'target_post_id_from_initial_url': None, 'target_post_id_from_initial_url': None,
'override_output_dir': None, 'override_output_dir': None,
'processed_post_ids': [], 'processed_post_ids': [],
'add_info_in_pdf': False,
} }
for item in self.fetched_posts_for_batch_update: for item in self.fetched_posts_for_batch_update:
@ -4784,18 +4961,33 @@ class DownloaderApp (QWidget ):
# --- THIS IS THE NEW, CORRECTED LOGIC --- # --- THIS IS THE NEW, CORRECTED LOGIC ---
full_profile_data = item.get('profile_data', {}) full_profile_data = item.get('profile_data', {})
saved_settings = full_profile_data.get('settings', {}) saved_settings = full_profile_data.get('settings', {})
# --- END OF NEW LOGIC ---
# 3. Construct the final arguments for this specific worker
# Start with a full set of defaults
args_for_this_worker = default_profile_settings.copy() args_for_this_worker = default_profile_settings.copy()
# Overwrite with any settings saved in the profile
# This is where {"filter_mode": "video"} from Maplestar.json is applied # Check the override flag we set in _show_empty_popup
args_for_this_worker.update(saved_settings) if getattr(self, 'override_update_profile_settings', False):
# Add all the live runtime arguments # If overriding, we rely PURELY on live_runtime_args (which reflect current UI state)
# We do NOT update with saved_settings.
# However, live_runtime_args only has *some* args. We need the FULL UI state.
# So we fetch the full UI state now.
current_ui_settings = self._get_current_ui_settings_as_dict(
api_url_override=saved_settings.get('api_url') # Preserve the URL from profile
)
args_for_this_worker.update(current_ui_settings)
# IMPORTANT: Update the profile data in memory so it gets saved correctly later
full_profile_data['settings'] = current_ui_settings
# OPTIONAL: Save the JSON immediately to disk to persist changes even if crash
creator_name = item.get('creator_name')
if creator_name:
self._save_creator_profile(creator_name, full_profile_data, self.session_file_path)
else:
# Standard behavior: Load saved settings, then overlay runtime overrides
args_for_this_worker.update(saved_settings)
# Apply live runtime args (always apply these last as they contain critical objects like emitters)
args_for_this_worker.update(live_runtime_args) args_for_this_worker.update(live_runtime_args)
# 4. Manually parse values from the constructed args # 4. Manually parse values from the constructed args
# Set post-specific data # Set post-specific data
@ -4936,6 +5128,7 @@ class DownloaderApp (QWidget ):
self.more_filter_scope = settings.get('more_filter_scope') self.more_filter_scope = settings.get('more_filter_scope')
self.text_export_format = settings.get('text_export_format', 'pdf') self.text_export_format = settings.get('text_export_format', 'pdf')
self.single_pdf_setting = settings.get('single_pdf_setting', False) self.single_pdf_setting = settings.get('single_pdf_setting', False)
self.add_info_in_pdf_setting = settings.get('add_info_in_pdf', False) # Load setting
if self.radio_more.isChecked() and self.more_filter_scope: if self.radio_more.isChecked() and self.more_filter_scope:
from .dialogs.MoreOptionsDialog import MoreOptionsDialog from .dialogs.MoreOptionsDialog import MoreOptionsDialog
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description" scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
@ -5135,7 +5328,13 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" Sorting collected posts by date (oldest first)...") self.log_signal.emit(" Sorting collected posts by date (oldest first)...")
sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z')) sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z'))
create_single_pdf_from_content(sorted_content, filepath, font_path, logger=self.log_signal.emit) create_single_pdf_from_content(
sorted_content,
filepath,
font_path,
add_info_page=self.add_info_in_pdf_setting, # Pass the flag here
logger=self.log_signal.emit
)
self.log_signal.emit("="*40) self.log_signal.emit("="*40)
def _add_to_history_candidates(self, history_data): def _add_to_history_candidates(self, history_data):
@ -5533,6 +5732,10 @@ class DownloaderApp (QWidget ):
if not self.finish_lock.acquire(blocking=False): if not self.finish_lock.acquire(blocking=False):
return return
# --- Flag to track if we still hold the lock ---
lock_held = True
# ----------------------------------------------------
try: try:
if self.is_finishing: if self.is_finishing:
return return
@ -5554,16 +5757,21 @@ class DownloaderApp (QWidget ):
self.log_signal.emit("🏁 Download of current item complete.") self.log_signal.emit("🏁 Download of current item complete.")
# --- QUEUE PROCESSING BLOCK ---
if self.is_processing_favorites_queue and self.favorite_download_queue: if self.is_processing_favorites_queue and self.favorite_download_queue:
self.log_signal.emit("✅ Item finished. Processing next in queue...") self.log_signal.emit("✅ Item finished. Processing next in queue...")
if self.download_thread and isinstance(self.download_thread, QThread): if self.download_thread and isinstance(self.download_thread, QThread):
self.download_thread.deleteLater() self.download_thread.deleteLater()
self.download_thread = None # This is the crucial line self.download_thread = None
self.is_finishing = False self.is_finishing = False
# FIX: Manual release + update flag
self.finish_lock.release() self.finish_lock.release()
lock_held = False
self._process_next_favorite_download() self._process_next_favorite_download()
return return
# ---------------------------------------------------------
if self.is_processing_favorites_queue: if self.is_processing_favorites_queue:
self.is_processing_favorites_queue = False self.is_processing_favorites_queue = False
@ -5618,7 +5826,6 @@ class DownloaderApp (QWidget ):
if self.download_thread: if self.download_thread:
if isinstance(self.download_thread, QThread): if isinstance(self.download_thread, QThread):
try: try:
# Disconnect signals to prevent any lingering connections
if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log) if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log)
if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal) if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal)
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished) if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished)
@ -5644,6 +5851,7 @@ class DownloaderApp (QWidget ):
) )
self.file_progress_label.setText("") self.file_progress_label.setText("")
# --- RETRY PROMPT BLOCK ---
if not cancelled_by_user and self.retryable_failed_files_info: if not cancelled_by_user and self.retryable_failed_files_info:
num_failed = len(self.retryable_failed_files_info) num_failed = len(self.retryable_failed_files_info)
reply = QMessageBox.question(self, "Retry Failed Downloads?", reply = QMessageBox.question(self, "Retry Failed Downloads?",
@ -5652,7 +5860,11 @@ class DownloaderApp (QWidget ):
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
self.is_finishing = False self.is_finishing = False
# FIX: Manual release + update flag
self.finish_lock.release() self.finish_lock.release()
lock_held = False
self._start_failed_files_retry_session() self._start_failed_files_retry_session()
return return
else: else:
@ -5663,9 +5875,9 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
self.retryable_failed_files_info.clear() self.retryable_failed_files_info.clear()
auto_retry_enabled = self.settings.value(AUTO_RETRY_ON_FINISH_KEY, False, type=bool) auto_retry_enabled = self.settings.value(AUTO_RETRY_ON_FINISH_KEY, False, type=bool)
# --- AUTO RETRY BLOCK ---
if not cancelled_by_user and auto_retry_enabled and self.permanently_failed_files_for_dialog: if not cancelled_by_user and auto_retry_enabled and self.permanently_failed_files_for_dialog:
num_files_to_retry = len(self.permanently_failed_files_for_dialog) num_files_to_retry = len(self.permanently_failed_files_for_dialog)
self.log_signal.emit("=" * 40) self.log_signal.emit("=" * 40)
@ -5680,7 +5892,6 @@ class DownloaderApp (QWidget ):
self.is_fetcher_thread_running = False self.is_fetcher_thread_running = False
# --- This is where the post-download action is triggered ---
if not cancelled_by_user and not self.is_processing_favorites_queue: if not cancelled_by_user and not self.is_processing_favorites_queue:
self._execute_post_download_action() self._execute_post_download_action()
@ -5688,8 +5899,12 @@ class DownloaderApp (QWidget ):
self._update_button_states_and_connections() self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
self.active_update_profile = None self.active_update_profile = None
finally: finally:
self.finish_lock.release() # --- Only release if we still hold it ---
if lock_held:
self.finish_lock.release()
# ---------------------------------------------
def _execute_post_download_action(self): def _execute_post_download_action(self):
"""Checks the settings and performs the chosen action after downloads complete.""" """Checks the settings and performs the chosen action after downloads complete."""
@ -5845,6 +6060,7 @@ class DownloaderApp (QWidget ):
'domain_override': domain_override_command, 'domain_override': domain_override_command,
'sfp_threshold': sfp_threshold_command, 'sfp_threshold': sfp_threshold_command,
'handle_unknown_mode': handle_unknown_command, 'handle_unknown_mode': handle_unknown_command,
'filter_mode':self .get_filter_mode (), 'filter_mode':self .get_filter_mode (),
'skip_zip':self .skip_zip_checkbox .isChecked (), 'skip_zip':self .skip_zip_checkbox .isChecked (),
'use_subfolders':self .use_subfolders_checkbox .isChecked (), 'use_subfolders':self .use_subfolders_checkbox .isChecked (),
@ -5868,13 +6084,11 @@ class DownloaderApp (QWidget ):
'target_post_id_from_initial_url':None , 'target_post_id_from_initial_url':None ,
'custom_folder_name':None , 'custom_folder_name':None ,
'num_file_threads':1 , 'num_file_threads':1 ,
'add_info_in_pdf': self.add_info_in_pdf_setting,
# --- START: ADDED COOKIE FIX ---
'use_cookie': self.use_cookie_checkbox.isChecked(), 'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(), 'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath, 'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir, 'app_base_dir': self.app_base_dir,
# --- END: ADDED COOKIE FIX ---
'manga_date_file_counter_ref':None , 'manga_date_file_counter_ref':None ,
} }
@ -6576,14 +6790,30 @@ class DownloaderApp (QWidget ):
return return
dialog = EmptyPopupDialog(self.user_data_path, self) dialog = EmptyPopupDialog(self.user_data_path, self)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
# --- NEW BATCH UPDATE LOGIC ---
# --- START OF MODIFICATION ---
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list: if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
self.active_update_profiles_list = dialog.update_profiles_list self.active_update_profiles_list = dialog.update_profiles_list
# --- NEW LOGIC: Check if user wants to load settings into UI ---
load_settings_requested = getattr(dialog, 'load_settings_into_ui_requested', False)
self.override_update_profile_settings = load_settings_requested
if load_settings_requested:
self.log_signal.emit(" User requested to edit settings. Loading profile settings into UI...")
# Load the settings from the FIRST profile into the main window UI
first_profile_settings = self.active_update_profiles_list[0]['data'].get('settings', {})
self._load_ui_from_settings_dict(first_profile_settings)
# We do NOT start the check immediately if we are editing settings?
# Actually, the workflow is: Check for Updates -> Find new posts -> THEN Download.
# So we should still proceed with the check, but note that we are in override mode.
self.log_signal.emit(f" Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...") self.log_signal.emit(f" Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...")
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...") self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
self._start_batch_update_check(self.active_update_profiles_list) self._start_batch_update_check(self.active_update_profiles_list)
# --- END OF MODIFICATION ---
# --- Original logic for adding creators to queue ---
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None # Ensure single update mode is off self.active_update_profile = None # Ensure single update mode is off
self.favorite_download_queue.clear() self.favorite_download_queue.clear()
@ -6830,11 +7060,19 @@ class DownloaderApp (QWidget ):
main_download_dir = self.dir_input.text().strip() main_download_dir = self.dir_input.text().strip()
should_create_artist_folder = False should_create_artist_folder = False
# --- Check for popup selection scope ---
if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS: if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS:
should_create_artist_folder = True should_create_artist_folder = True
# --- Check for global "Artist Folders" scope ---
elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS: elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS:
should_create_artist_folder = True should_create_artist_folder = True
# --- NEW: Check for forced folder flag from batch ---
if self.current_processing_favorite_item_info.get('force_artist_folder'):
should_create_artist_folder = True
# ---------------------------------------------------
if should_create_artist_folder and main_download_dir: if should_create_artist_folder and main_download_dir:
folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder') folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
item_specific_folder_name = clean_folder_name(folder_name_key) item_specific_folder_name = clean_folder_name(folder_name_key)

View File

@ -137,6 +137,12 @@ def extract_post_info(url_string):
stripped_url = url_string.strip() 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 Check ---
rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url) rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
if rule34video_match: if rule34video_match:

View File

@ -307,14 +307,18 @@ def setup_ui(main_app):
simpcity_settings_label = QLabel("⚙️ SimpCity Download Options:") simpcity_settings_label = QLabel("⚙️ SimpCity Download Options:")
simpcity_settings_layout.addWidget(simpcity_settings_label) simpcity_settings_layout.addWidget(simpcity_settings_label)
# Checkbox row # Checkbox row
simpcity_checkboxes_layout = QHBoxLayout() 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_pixeldrain_cb = QCheckBox("Download Pixeldrain")
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su") main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega") main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr") main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile") 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_pixeldrain_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb) simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_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) simpcity_settings_layout.addLayout(simpcity_checkboxes_layout)
# --- START NEW CODE --- # --- START NEW CODE ---
# Create the second, dedicated set of cookie controls for SimpCity
simpcity_cookie_layout = QHBoxLayout() simpcity_cookie_layout = QHBoxLayout()
simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
simpcity_cookie_label = QLabel("Cookie:") simpcity_cookie_label = QLabel("Cookie:")

View File

@ -20,7 +20,6 @@
│ ├── DejaVuSansCondensed-BoldOblique.ttf │ ├── DejaVuSansCondensed-BoldOblique.ttf
│ ├── DejaVuSansCondensed-Oblique.ttf │ ├── DejaVuSansCondensed-Oblique.ttf
│ └── DejaVuSansCondensed.ttf │ └── DejaVuSansCondensed.ttf
├── directory_tree.txt
├── main.py ├── main.py
├── src/ ├── src/
│ ├── __init__.py │ ├── __init__.py