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):
"""
Orchestrates the download process using a producer-consumer model.
The main thread scrapes image URLs and puts them in a queue.
A pool of worker threads consumes from the queue to download images concurrently.
"""
scraper = cloudscraper.create_scraper()
all_failed_files = [] # Track all failures across chapters
try:
progress_callback(" [Hentai2Read] Scraping series page for all metadata...")
@ -39,8 +38,7 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
os.makedirs(final_save_path, exist_ok=True)
# This function now scrapes and downloads simultaneously
dl_count, skip_count = _process_and_download_chapter(
dl_count, skip_count, chapter_failures = _process_and_download_chapter(
chapter_url=chapter['url'],
save_path=final_save_path,
scraper=scraper,
@ -51,9 +49,22 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
total_downloaded_count += dl_count
total_skipped_count += skip_count
if chapter_failures:
all_failed_files.extend(chapter_failures)
overall_progress_callback(total_chapters, idx + 1)
if check_pause_func(): break
# --- FINAL SUMMARY OF FAILURES ---
if all_failed_files:
progress_callback("\n" + "="*40)
progress_callback(f"❌ SUMMARY: {len(all_failed_files)} files failed permanently after 10 retries:")
for fail_msg in all_failed_files:
progress_callback(f"{fail_msg}")
progress_callback("="*40 + "\n")
else:
progress_callback("\n✅ All chapters processed successfully with no permanent failures.")
return total_downloaded_count, total_skipped_count
except Exception as e:
@ -63,9 +74,8 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
def _get_series_metadata(start_url, progress_callback, scraper):
"""
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
Includes a retry mechanism for the initial connection.
"""
max_retries = 4 # Total number of attempts (1 initial + 3 retries)
max_retries = 4
last_exception = None
soup = None
@ -77,8 +87,6 @@ def _get_series_metadata(start_url, progress_callback, scraper):
response = scraper.get(start_url, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
# If successful, clear exception and break the loop
last_exception = None
break
@ -86,8 +94,8 @@ def _get_series_metadata(start_url, progress_callback, scraper):
last_exception = e
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1:
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s
continue # Try again
time.sleep(2 * (attempt + 1))
continue
if last_exception:
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
@ -96,23 +104,36 @@ def _get_series_metadata(start_url, progress_callback, scraper):
try:
series_title = "Unknown Series"
artist_name = None
# 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:
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)
for b_tag in metadata_list.find_all('b'):
label = b_tag.get_text(strip=True)
if label in ("Artist", "Author"):
if "Artist" in label or "Author" in label:
a_tag = b_tag.find_next_sibling('a')
if a_tag:
artist_name = a_tag.get_text(strip=True)
if label == "Artist":
break
break
top_level_folder_name = artist_name if artist_name else series_title
if not artist_name:
artist_link = soup.find('a', href=re.compile(r'/hentai-list/artist/'))
if artist_link:
artist_name = artist_link.get_text(strip=True)
if artist_name:
top_level_folder_name = f"{artist_name} - {series_title}"
else:
top_level_folder_name = series_title
chapter_links = soup.select("div.media a.pull-left.font-w600")
if not chapter_links:
@ -124,7 +145,7 @@ def _get_series_metadata(start_url, progress_callback, scraper):
]
chapters_to_process.reverse()
progress_callback(f" [Hentai2Read] ✅ Found Artist/Series: '{top_level_folder_name}'")
progress_callback(f" [Hentai2Read] ✅ Found Metadata: '{top_level_folder_name}'")
progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
return top_level_folder_name, chapters_to_process
@ -136,69 +157,102 @@ def _get_series_metadata(start_url, progress_callback, scraper):
def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
"""
Uses a producer-consumer pattern to download a chapter.
The main thread (producer) scrapes URLs one by one.
Worker threads (consumers) download the URLs as they are found.
Includes RETRY LOGIC and ACTIVE LOGGING.
"""
task_queue = queue.Queue()
num_download_threads = 8
download_stats = {'downloaded': 0, 'skipped': 0}
failed_files_list = []
def downloader_worker():
"""The function that each download thread will run."""
worker_scraper = cloudscraper.create_scraper()
while True:
try:
# Get a task from the queue
task = task_queue.get()
# The sentinel value to signal the end
if task is None:
break
task = task_queue.get()
if task is None:
task_queue.task_done()
break
filepath, img_url = task
filename = os.path.basename(filepath)
if os.path.exists(filepath):
# We log skips to show it's checking files
progress_callback(f" -> Skip (Exists): '{filename}'")
download_stats['skipped'] += 1
task_queue.task_done()
continue
# --- RETRY LOGIC START ---
success = False
# UNCOMMENTED: Log the start of download so you see activity
progress_callback(f" Downloading: '{filename}'...")
for attempt in range(10): # Try 10 times
try:
if attempt > 0:
progress_callback(f" ⚠️ Retrying '{filename}' (Attempt {attempt+1}/10)...")
time.sleep(2)
filepath, img_url = task
if os.path.exists(filepath):
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'")
download_stats['skipped'] += 1
else:
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...")
response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
response.raise_for_status()
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
download_stats['downloaded'] += 1
except Exception as e:
progress_callback(f" ❌ Download failed for task. Error: {e}")
download_stats['skipped'] += 1
finally:
task_queue.task_done()
success = True
# UNCOMMENTED: Log success
progress_callback(f" ✅ Downloaded: '{filename}'")
break
except Exception as e:
if attempt == 9:
progress_callback(f" ❌ Failed '{filename}' after 10 attempts: {e}")
if not success:
failed_files_list.append(f"{filename} (Chapter: {os.path.basename(save_path)})")
# Clean up empty file if failed
if os.path.exists(filepath):
try:
os.remove(filepath)
except OSError: pass
task_queue.task_done()
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
for _ in range(num_download_threads):
executor.submit(downloader_worker)
page_number = 1
progress_callback(" [Hentai2Read] Scanning pages...") # Initial log
while True:
if check_pause_func(): break
if page_number > 300: # Safety break
if page_number > 300:
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
break
# Log occasionally to show scanning is alive
if page_number % 10 == 0:
progress_callback(f" [Hentai2Read] Scanned {page_number} pages so far...")
page_url_to_check = f"{chapter_url}{page_number}/"
try:
page_response = None
page_last_exception = None
for page_attempt in range(3): # 3 attempts for sub-pages
for page_attempt in range(3):
try:
page_response = scraper.get(page_url_to_check, timeout=30)
page_last_exception = None
break
except Exception as e:
page_last_exception = e
time.sleep(1) # Short delay for page scraping retries
time.sleep(1)
if page_last_exception:
raise page_last_exception # Give up after 3 tries
raise page_last_exception
if page_response.history or page_response.status_code != 200:
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
@ -209,7 +263,7 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
img_src = img_tag.get("src") if img_tag else None
if not img_tag or img_src == "https://static.hentai.direct/hentai":
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
progress_callback(f" [Hentai2Read] End of chapter detected (Last page reached at {page_number}).")
break
normalized_img_src = urljoin(page_response.url, img_src)
@ -220,15 +274,19 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
task_queue.put((filepath, normalized_img_src))
page_number += 1
time.sleep(0.1) # Small delay between scraping pages
time.sleep(0.1)
except Exception as e:
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
break
# Signal workers to exit
for _ in range(num_download_threads):
task_queue.put(None)
# Wait for all tasks to complete
task_queue.join()
executor.shutdown(wait=True)
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
return download_stats['downloaded'], download_stats['skipped']
progress_callback(f" Chapter complete. Processed {page_number - 1} images.")
return download_stats['downloaded'], download_stats['skipped'], failed_files_list

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.")
time.sleep(0.5)
logger(" Post fetching resumed.")
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags,content"
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
max_retries = 3
@ -39,10 +39,10 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(log_message)
try:
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
with requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict) as response:
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
except requests.exceptions.RequestException as e:
# Handle 403 error on the FIRST page as a rate limit/block
@ -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}"
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:
scraper = cloudscraper.create_scraper()
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict)
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:
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
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):
@ -115,10 +120,11 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
logger(f" Fetching comments: {comments_api_url}")
try:
response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict)
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
# FIX: Use context manager
with requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict) as response:
response.raise_for_status()
response.encoding = 'utf-8'
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Error fetching comments for post {post_id}: {e}")
except ValueError as e:
@ -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}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try:
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
direct_response.raise_for_status()
direct_response.encoding = 'utf-8'
direct_post_data = direct_response.json()
# FIX: Use context manager
with requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api) as direct_response:
direct_response.raise_for_status()
direct_response.encoding = 'utf-8'
direct_post_data = direct_response.json()
if isinstance(direct_post_data, list) and direct_post_data:
direct_post_data = direct_post_data[0]
if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
@ -311,7 +319,6 @@ def download_from_api(
current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
# --- START OF MODIFIED BLOCK ---
while True:
if pause_event and pause_event.is_set():
logger(" Post fetching loop paused...")
@ -334,7 +341,6 @@ def download_from_api(
break
try:
# 1. Fetch the raw batch of posts
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
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}).")
@ -350,7 +356,6 @@ def download_from_api(
traceback.print_exc()
break
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
if not raw_posts_batch:
if target_post_id and not processed_target_post_flag:
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
@ -359,9 +364,8 @@ def download_from_api(
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
else:
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
break # This break is now correct.
break
# 3. Filter the batch against processed IDs
posts_batch_to_yield = raw_posts_batch
original_count = len(raw_posts_batch)
@ -371,25 +375,17 @@ def download_from_api(
if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
# 4. Process the *filtered* batch
if target_post_id and not processed_target_post_flag:
# Still searching for a specific post
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
if matching_post:
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
yield [matching_post]
processed_target_post_flag = True
elif not target_post_id:
# Downloading a creator feed
if posts_batch_to_yield:
# We found new posts on this page, yield them
yield posts_batch_to_yield
elif original_count > 0:
# We found 0 new posts, but the page *did* have posts (they were just skipped).
# Log this and continue to the next page.
logger(f" No new posts found on page {current_page_num}. Checking next page...")
# If original_count was 0, the `if not raw_posts_batch:` check
# already caught it and broke the loop.
if processed_target_post_flag:
break
@ -397,7 +393,6 @@ def download_from_api(
current_offset += page_size
current_page_num += 1
time.sleep(0.6)
# --- END OF MODIFIED BLOCK ---
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")

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
)
from ..config.constants import *
from ..ui.dialogs.SinglePDF import create_individual_pdf
def robust_clean_name(name):
"""A more robust function to remove illegal characters for filenames and folders."""
if not name:
return ""
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\'\[\]]'
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\']'
cleaned_name = re.sub(illegal_chars_pattern, '', name)
cleaned_name = cleaned_name.strip(' .')
@ -132,6 +133,8 @@ class PostProcessorWorker:
sfp_threshold=None,
handle_unknown_mode=False,
creator_name_cache=None,
add_info_in_pdf=False
):
self.post = post_data
self.download_root = download_root
@ -205,6 +208,10 @@ class PostProcessorWorker:
self.sfp_threshold = sfp_threshold
self.handle_unknown_mode = handle_unknown_mode
self.creator_name_cache = creator_name_cache
#-- New assign --
self.add_info_in_pdf = add_info_in_pdf
#-- New assign --
if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found.")
@ -974,6 +981,92 @@ class PostProcessorWorker:
else:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
def _get_manga_style_filename_for_post(self, post_title, original_ext):
"""Generates a filename based on manga style, using post data."""
if self.manga_filename_style == STYLE_POST_TITLE:
cleaned_post_title_base = robust_clean_name(post_title.strip() if post_title and post_title.strip() else "post")
return f"{cleaned_post_title_base}{original_ext}"
elif self.manga_filename_style == STYLE_CUSTOM:
try:
def format_date(date_str):
if not date_str or 'NoDate' in date_str:
return "NoDate"
try:
dt_obj = datetime.fromisoformat(date_str)
strftime_format = self.manga_custom_date_format.replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
return dt_obj.strftime(strftime_format)
except (ValueError, TypeError):
return date_str.split('T')[0]
service = self.service.lower()
user_id = str(self.user_id)
creator_name = self.creator_name_cache.get((service, user_id), user_id)
added_date = self.post.get('added')
published_date = self.post.get('published')
edited_date = self.post.get('edited')
format_values = {
'id': str(self.post.get('id', '')),
'user': user_id,
'creator_name': creator_name,
'service': self.service,
'title': str(self.post.get('title', '')),
'name': robust_clean_name(post_title), # Use post title as a fallback 'name'
'added': format_date(added_date or published_date),
'published': format_date(published_date),
'edited': format_date(edited_date or published_date)
}
custom_base_name = self.manga_custom_filename_format.format(**format_values)
cleaned_custom_name = robust_clean_name(custom_base_name)
return f"{cleaned_custom_name}{original_ext}"
except (KeyError, IndexError, ValueError) as e:
self.logger(f"⚠️ Custom format error for text export: {e}. Falling back to post title.")
return f"{robust_clean_name(post_title.strip() or 'untitled_post')}{original_ext}"
elif self.manga_filename_style == STYLE_DATE_POST_TITLE:
published_date_str = self.post.get('published')
added_date_str = self.post.get('added')
formatted_date_str = "nodate"
if published_date_str:
try:
formatted_date_str = published_date_str.split('T')[0]
except Exception:
pass
elif added_date_str:
try:
formatted_date_str = added_date_str.split('T')[0]
except Exception:
pass
cleaned_post_title_for_filename = robust_clean_name(post_title.strip() or "post")
base_name_for_style = f"{formatted_date_str}_{cleaned_post_title_for_filename}"
return f"{base_name_for_style}{original_ext}"
elif self.manga_filename_style == STYLE_POST_ID:
post_id = str(self.post.get('id', 'unknown_id'))
return f"{post_id}{original_ext}"
elif self.manga_filename_style == STYLE_ORIGINAL_NAME:
published_date_str = self.post.get('published') or self.post.get('added')
formatted_date_str = "nodate"
if published_date_str:
try:
formatted_date_str = published_date_str.split('T')[0]
except Exception:
pass
# Use post title as the name part, as there is no "original filename" for the text export.
cleaned_post_title_base = robust_clean_name(post_title.strip() or "untitled_post")
return f"{formatted_date_str}_{cleaned_post_title_base}{original_ext}"
# Default fallback
return f"{robust_clean_name(post_title.strip() or 'untitled_post')}{original_ext}"
def process(self):
result_tuple = (0, 0, [], [], [], None, None)
try:
@ -1269,6 +1362,8 @@ class PostProcessorWorker:
if self.filter_mode == 'text_only' and not self.extract_links_only:
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
post_title_lower = post_title.lower()
# --- Skip Words Check ---
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
for skip_word in self.skip_words_list:
if skip_word.lower() in post_title_lower:
@ -1287,6 +1382,7 @@ class PostProcessorWorker:
comments_data = []
final_post_data = post_data
# --- Content Fetching ---
if self.text_only_scope == 'content' and 'content' not in final_post_data:
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
parsed_url = urlparse(self.api_url_input)
@ -1304,6 +1400,8 @@ class PostProcessorWorker:
api_domain = parsed_url.netloc
comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event)
if comments_data:
# For TXT/DOCX export, we format comments here.
# For PDF, we pass the raw list to the generator.
comment_texts = []
for comment in comments_data:
user = comment.get('commenter_name', 'Unknown User')
@ -1335,23 +1433,43 @@ class PostProcessorWorker:
self._emit_signal('worker_finished', result_tuple)
return result_tuple
# --- Metadata Preparation ---
# Prepare all data needed for the info page or JSON dump
service_str = self.service
user_id_str = str(self.user_id)
post_id_str = str(post_id)
creator_key = (service_str.lower(), user_id_str)
# Resolve creator name using the cache passed from main_window
creator_name = user_id_str
if self.creator_name_cache:
creator_name = self.creator_name_cache.get(creator_key, user_id_str)
common_content_data = {
'title': post_title,
'published': self.post.get('published') or self.post.get('added'),
'service': service_str,
'user': user_id_str,
'id': post_id_str,
'tags': self.post.get('tags'),
'original_link': post_page_url,
'creator_name': creator_name
}
# --- Single PDF Mode (Save Temp JSON) ---
if self.single_pdf_mode:
content_data = {
'title': post_title,
'published': self.post.get('published') or self.post.get('added')
}
if self.text_only_scope == 'comments':
if not comments_data:
result_tuple = (0, 0, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple)
return result_tuple
content_data['comments'] = comments_data
common_content_data['comments'] = comments_data
else:
if not cleaned_text.strip():
result_tuple = (0, 0, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple)
return result_tuple
content_data['content'] = cleaned_text
common_content_data['content'] = cleaned_text
temp_dir = os.path.join(self.app_base_dir, "appdata")
os.makedirs(temp_dir, exist_ok=True)
@ -1359,7 +1477,7 @@ class PostProcessorWorker:
temp_filepath = os.path.join(temp_dir, temp_filename)
try:
with open(temp_filepath, 'w', encoding='utf-8') as f:
json.dump(content_data, f, indent=2)
json.dump(common_content_data, f, indent=2)
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
result_tuple = (0, 0, [], [], [], None, temp_filepath)
self._emit_signal('worker_finished', result_tuple)
@ -1369,82 +1487,67 @@ class PostProcessorWorker:
result_tuple = (0, 0, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple)
return result_tuple
# --- Individual File Mode ---
else:
file_extension = self.text_export_format
txt_filename = clean_filename(post_title) + f".{file_extension}"
txt_filename = ""
if self.manga_mode_active:
txt_filename = self._get_manga_style_filename_for_post(post_title, f".{file_extension}")
self.logger(f" Applying Renaming Mode. Generated filename: '{txt_filename}'")
else:
txt_filename = clean_filename(post_title) + f".{file_extension}"
final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename)
try:
os.makedirs(determined_post_save_path_for_history, exist_ok=True)
base, ext = os.path.splitext(final_save_path)
counter = 1
while os.path.exists(final_save_path):
final_save_path = f"{base}_{counter}{ext}"
counter += 1
# --- PDF Generation ---
if file_extension == 'pdf':
if FPDF:
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
pdf = PDF()
base_path = self.project_root_dir
font_path = ""
bold_font_path = ""
# Font setup
font_path = ""
if self.project_root_dir:
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
if base_path:
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
default_font_family = 'DejaVu'
except Exception as font_error:
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
default_font_family = 'Arial'
pdf.add_page()
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(0, 10, post_title)
pdf.ln(10)
if self.text_only_scope == 'comments':
if not comments_data:
self.logger(" -> Skip PDF Creation: No comments to process.")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
self._emit_signal('worker_finished', result_tuple)
return result_tuple
for i, comment in enumerate(comments_data):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, user)
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if i < len(comments_data) - 1:
pdf.ln(5)
pdf.cell(0, 0, '', border='T')
pdf.ln(5)
else:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(0, 7, cleaned_text)
pdf.output(final_save_path)
# Add content specific fields for the generator
if self.text_only_scope == 'comments':
common_content_data['comments_list_for_pdf'] = comments_data
else:
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
common_content_data['content_text_for_pdf'] = cleaned_text
# Call the centralized function
success = create_individual_pdf(
post_data=common_content_data,
output_filename=final_save_path,
font_path=font_path,
add_info_page=self.add_info_in_pdf, # <--- NEW PARAMETER
logger=self.logger
)
if not success:
raise Exception("PDF generation failed (check logs)")
# --- DOCX Generation ---
elif file_extension == 'docx':
if Document:
self.logger(f" Converting to DOCX...")
document = Document()
# Add simple header info if desired, or keep pure text
if self.add_info_in_pdf:
document.add_heading(post_title, 0)
document.add_paragraph(f"Date: {common_content_data['published']}")
document.add_paragraph(f"Creator: {common_content_data['creator_name']}")
document.add_paragraph(f"URL: {common_content_data['original_link']}")
document.add_page_break()
document.add_paragraph(cleaned_text)
document.save(final_save_path)
else:
@ -1452,9 +1555,20 @@ class PostProcessorWorker:
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
else: # TXT file
# --- TXT Generation ---
else:
content_to_write = cleaned_text
# Optional: Add simple text header if "Add Info" is checked
if self.add_info_in_pdf:
header = (f"Title: {post_title}\n"
f"Date: {common_content_data['published']}\n"
f"Creator: {common_content_data['creator_name']}\n"
f"URL: {common_content_data['original_link']}\n"
f"{'-'*40}\n\n")
content_to_write = header + cleaned_text
with open(final_save_path, 'w', encoding='utf-8') as f:
f.write(cleaned_text)
f.write(content_to_write)
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
@ -1467,6 +1581,7 @@ class PostProcessorWorker:
self._emit_signal('worker_finished', result_tuple)
return result_tuple
if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match:
self.logger(f" -> Skip Post (Renaming Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.")
self._emit_signal('missed_character_post', post_title, "Renaming Mode: No title match for character filter (Title/Both scope)")

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 .simp_city_downloader_thread import SimpCityDownloadThread
from .toonily_downloader_thread import ToonilyDownloadThread
from .deviantart_downloader_thread import DeviantArtDownloadThread
def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
"""
@ -175,6 +175,17 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
# id1 contains the full URL or album ID from extract_post_info
return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app)
# Handler for DeviantArt
if service == 'deviantart':
main_app.log_signal.emit(f" DeviantArt URL detected. Starting dedicated downloader.")
return DeviantArtDownloadThread(
url=api_url,
output_dir=effective_output_dir_for_run,
pause_event=main_app.pause_event,
cancellation_event=main_app.cancellation_event,
parent=main_app
)
# ----------------------
# --- Fallback ---
# If no specific handler matched based on service name or URL pattern, return None.
# This signals main_window.py to use the generic BackendDownloadThread/PostProcessorWorker

View File

@ -254,6 +254,7 @@ class SimpCityDownloadThread(QThread):
self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
self.should_dl_images = self.parent_app.simpcity_dl_images_cb.isChecked()
self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
@ -288,8 +289,10 @@ class SimpCityDownloadThread(QThread):
enriched_jobs = self._get_enriched_jobs(jobs)
if enriched_jobs:
for job in enriched_jobs:
if job['type'] == 'image': self.image_queue.put(job)
if job['type'] == 'image':
if self.should_dl_images: self.image_queue.put(job)
else: self.service_queue.put(job)
else:
base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
@ -347,11 +350,14 @@ class SimpCityDownloadThread(QThread):
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
end_of_thread = True
else:
for job in enriched_jobs:
self.processed_job_urls.add(job.get('url'))
if job['type'] == 'image': self.image_queue.put(job)
if job['type'] == 'image':
if self.should_dl_images: self.image_queue.put(job)
else: self.service_queue.put(job)
page_fetch_successful = True; break
except requests.exceptions.HTTPError as e:
if e.response.status_code in [403, 404]:

View File

@ -7,7 +7,6 @@ from PyQt5.QtCore import Qt
class CustomFilenameDialog(QDialog):
"""A dialog for creating a custom filename format string."""
# --- REPLACE THE 'AVAILABLE_KEYS' LIST WITH THIS DICTIONARY ---
DISPLAY_KEY_MAP = {
"PostID": "id",
"CreatorName": "creator_name",
@ -19,7 +18,10 @@ class CustomFilenameDialog(QDialog):
"name": "name"
}
def __init__(self, current_format, current_date_format, parent=None):
# STRICT LIST: Only these three will be clickable for DeviantArt
DA_ALLOWED_KEYS = ["creator_name", "title", "published"]
def __init__(self, current_format, current_date_format, parent=None, is_deviantart=False):
super().__init__(parent)
self.setWindowTitle("Custom Filename Format")
self.setMinimumWidth(500)
@ -31,9 +33,11 @@ class CustomFilenameDialog(QDialog):
layout = QVBoxLayout(self)
# --- Description ---
description_label = QLabel(
"Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format."
)
desc_text = "Create a filename format using placeholders. The date/time values will be automatically formatted."
if is_deviantart:
desc_text += "\n\n(DeviantArt Mode: Only Creator Name, Title, and Upload Date are available. Other buttons are disabled.)"
description_label = QLabel(desc_text)
description_label.setWordWrap(True)
layout.addWidget(description_label)
@ -42,15 +46,20 @@ class CustomFilenameDialog(QDialog):
layout.addWidget(format_label)
self.format_input = QLineEdit(self)
self.format_input.setText(self.current_format)
self.format_input.setPlaceholderText("e.g., {published} {title} {id}")
if is_deviantart:
self.format_input.setPlaceholderText("e.g., {published} {title} {creator_name}")
else:
self.format_input.setPlaceholderText("e.g., {published} {title} {id}")
layout.addWidget(self.format_input)
# --- Date Format Input ---
date_format_label = QLabel("Date Format (for {added}, {published}, {edited}):")
date_format_label = QLabel("Date Format (for {published}):")
layout.addWidget(date_format_label)
self.date_format_input = QLineEdit(self)
self.date_format_input.setText(self.current_date_format)
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD or DD-MM-YYYY")
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD")
layout.addWidget(self.date_format_input)
# --- Available Keys Display ---
@ -62,7 +71,20 @@ class CustomFilenameDialog(QDialog):
for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
key_button = QPushButton(f"{{{display_key}}}")
# Use a lambda to pass the correct internal key when the button is clicked
# --- DeviantArt Logic ---
if is_deviantart:
if internal_key in self.DA_ALLOWED_KEYS:
# Active buttons: Bold text, enabled
key_button.setStyleSheet("font-weight: bold; color: black;")
key_button.setEnabled(True)
else:
# Inactive buttons: Disabled (Cannot be clicked)
key_button.setEnabled(False)
key_button.setToolTip("Not available for DeviantArt")
# ------------------------
# Use a lambda to pass the correct internal key when clicked
key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
keys_layout.addWidget(key_button)
keys_layout.addStretch()
@ -81,9 +103,7 @@ class CustomFilenameDialog(QDialog):
self.format_input.setFocus()
def get_format_string(self):
"""Returns the final format string from the input field."""
return self.format_input.text().strip()
def get_date_format_string(self):
"""Returns the date format string from its input field."""
return self.date_format_input.text().strip()

View File

@ -156,6 +156,9 @@ class EmptyPopupDialog (QDialog ):
# --- MODIFIED: Store a list of profiles now ---
self.update_profiles_list = None
# --- NEW: Flag to indicate if settings should load to UI ---
self.load_settings_into_ui_requested = False
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
self.update_profile_data = None
self.update_creator_name = None
@ -341,6 +344,9 @@ class EmptyPopupDialog (QDialog ):
if dialog.exec_() == QDialog.Accepted:
# --- MODIFIED: Get a list of profiles now ---
selected_profiles = dialog.get_selected_profiles()
# --- NEW: Get the checkbox state ---
self.load_settings_into_ui_requested = dialog.should_load_into_ui()
if selected_profiles:
try:
# --- MODIFIED: Store the list ---

View File

@ -11,17 +11,16 @@ class MoreOptionsDialog(QDialog):
SCOPE_CONTENT = "content"
SCOPE_COMMENTS = "comments"
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False, add_info_checked=False):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("More Options")
self.setMinimumWidth(350)
# ... (Layout and other widgets remain the same) ...
layout = QVBoxLayout(self)
self.description_label = QLabel("Please choose the scope for the action:")
layout.addWidget(self.description_label)
self.radio_button_group = QButtonGroup(self)
self.radio_content = QRadioButton("Description/Content")
self.radio_comments = QRadioButton("Comments")
@ -50,14 +49,20 @@ class MoreOptionsDialog(QDialog):
export_layout.addStretch()
layout.addLayout(export_layout)
# --- UPDATED: Single PDF Checkbox ---
# --- Single PDF Checkbox ---
self.single_pdf_checkbox = QCheckBox("Single PDF")
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
self.single_pdf_checkbox.setChecked(single_pdf_checked)
layout.addWidget(self.single_pdf_checkbox)
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
# --- NEW: Add Info Checkbox ---
self.add_info_checkbox = QCheckBox("Add info in PDF")
self.add_info_checkbox.setToolTip("If checked, adds a first page with post details (Title, Date, Link, Creator, Tags, etc.).")
self.add_info_checkbox.setChecked(add_info_checked)
layout.addWidget(self.add_info_checkbox)
self.format_combo.currentTextChanged.connect(self.update_checkbox_states)
self.update_checkbox_states(self.format_combo.currentText())
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
@ -65,12 +70,18 @@ class MoreOptionsDialog(QDialog):
layout.addWidget(self.button_box)
self.setLayout(layout)
self._apply_theme()
def update_single_pdf_checkbox_state(self, text):
"""Enable the Single PDF checkbox only if the format is PDF."""
def update_checkbox_states(self, text):
"""Enable PDF-specific checkboxes only if the format is PDF."""
is_pdf = (text.upper() == "PDF")
self.single_pdf_checkbox.setEnabled(is_pdf)
self.add_info_checkbox.setEnabled(is_pdf)
if not is_pdf:
self.single_pdf_checkbox.setChecked(False)
# We don't uncheck add_info necessarily, just disable it,
# but unchecking is safer visually to imply "won't happen"
self.add_info_checkbox.setChecked(False)
def get_selected_scope(self):
if self.radio_comments.isChecked():
@ -84,13 +95,14 @@ class MoreOptionsDialog(QDialog):
"""Returns the state of the Single PDF checkbox."""
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
def get_add_info_state(self):
"""Returns the state of the Add Info checkbox."""
return self.add_info_checkbox.isChecked() and self.add_info_checkbox.isEnabled()
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")

View File

@ -4,24 +4,22 @@ try:
from fpdf import FPDF
FPDF_AVAILABLE = True
# --- FIX: Move the class definition inside the try block ---
class PDF(FPDF):
"""Custom PDF class to handle headers and footers."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.font_family_main = 'Arial'
def header(self):
pass
def footer(self):
self.set_y(-15)
if self.font_family:
self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.set_font(self.font_family_main, '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
except ImportError:
FPDF_AVAILABLE = False
# If the import fails, FPDF and PDF will not be defined,
# but the program won't crash here.
FPDF = None
PDF = None
@ -31,12 +29,169 @@ def strip_html_tags(text):
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
def _setup_pdf_fonts(pdf, font_path, logger=print):
"""Helper to setup fonts for the PDF instance."""
bold_font_path = ""
default_font = 'Arial'
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try:
if font_path and os.path.exists(font_path):
pdf.add_font('DejaVu', '', font_path, uni=True)
default_font = 'DejaVu'
if os.path.exists(bold_font_path):
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
else:
pdf.add_font('DejaVu', 'B', font_path, uni=True)
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
default_font = 'Arial'
pdf.font_family_main = default_font
return default_font
def add_metadata_page(pdf, post, font_family):
"""Adds a dedicated metadata page to the PDF with clickable links."""
pdf.add_page()
pdf.set_font(font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='C')
pdf.ln(10)
pdf.set_font(font_family, '', 11)
def add_info_row(label, value, link_url=None):
if not value: return
# Write Label (Bold)
pdf.set_font(font_family, 'B', 11)
pdf.write(8, f"{label}: ")
# Write Value
if link_url:
# Styling for clickable link: Blue + Underline
pdf.set_text_color(0, 0, 255)
# Check if font supports underline style directly or just use 'U'
# FPDF standard allows 'U' in style string.
# We use 'U' combined with the font family.
# Note: DejaVu implementation in fpdf2 might handle 'U' automatically or ignore it depending on version,
# but setting text color indicates link clearly enough usually.
pdf.set_font(font_family, 'U', 11)
# Pass the URL to the 'link' parameter
pdf.multi_cell(w=0, h=8, txt=str(value), link=link_url)
# Reset styles
pdf.set_text_color(0, 0, 0)
pdf.set_font(font_family, '', 11)
else:
pdf.set_font(font_family, '', 11)
pdf.multi_cell(w=0, h=8, txt=str(value))
pdf.ln(2)
date_str = post.get('published') or post.get('added') or 'Unknown'
add_info_row("Date Uploaded", date_str)
creator = post.get('creator_name') or post.get('user') or 'Unknown'
add_info_row("Creator", creator)
add_info_row("Service", post.get('service', 'Unknown'))
link = post.get('original_link')
if not link and post.get('service') and post.get('user') and post.get('id'):
link = f"https://kemono.su/{post['service']}/user/{post['user']}/post/{post['id']}"
# Pass 'link' as both the text value AND the URL target
add_info_row("Original Link", link, link_url=link)
tags = post.get('tags')
if tags:
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
add_info_row("Tags", tags_str)
pdf.ln(10)
pdf.cell(0, 0, border='T')
pdf.ln(10)
def create_individual_pdf(post_data, output_filename, font_path, add_info_page=False, add_comments=False, logger=print):
"""
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
Creates a PDF for a single post.
Supports optional metadata page and appending comments.
"""
if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
logger("❌ PDF Creation failed: 'fpdf2' library not installed.")
return False
pdf = PDF()
font_family = _setup_pdf_fonts(pdf, font_path, logger)
if add_info_page:
# add_metadata_page adds the page start itself
add_metadata_page(pdf, post_data, font_family)
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
else:
pdf.add_page()
# Only add the Title header manually if we didn't add the info page
# (Because the info page already contains the title at the top)
if not add_info_page:
pdf.set_font(font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, txt=post_data.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
content_text = post_data.get('content_text_for_pdf')
comments_list = post_data.get('comments_list_for_pdf')
# 1. Write Content
if content_text:
pdf.set_font(font_family, '', 12)
pdf.multi_cell(w=0, h=7, txt=content_text)
pdf.ln(10)
# 2. Write Comments (if enabled and present)
if comments_list and (add_comments or not content_text):
if add_comments and content_text:
pdf.add_page()
pdf.set_font(font_family, 'B', 14)
pdf.cell(0, 10, "Comments", ln=True)
pdf.ln(5)
for i, comment in enumerate(comments_list):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(font_family, '', 10)
pdf.write(8, "Comment by: ")
pdf.set_font(font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(font_family, '', 11)
pdf.multi_cell(w=0, h=7, txt=body)
if i < len(comments_list) - 1:
pdf.ln(3)
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
try:
pdf.output(output_filename)
return True
except Exception as e:
logger(f"❌ Error saving PDF '{os.path.basename(output_filename)}': {e}")
return False
def create_single_pdf_from_content(posts_data, output_filename, font_path, add_info_page=False, logger=print):
"""
Creates a single, continuous PDF from multiple posts.
"""
if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed.")
return False
if not posts_data:
@ -44,34 +199,21 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return False
pdf = PDF()
default_font_family = 'DejaVu'
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()
font_family = _setup_pdf_fonts(pdf, font_path, logger)
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
for i, post in enumerate(posts_data):
if i > 0:
# This ensures every post after the first gets its own page.
if add_info_page:
add_metadata_page(pdf, post, font_family)
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
else:
pdf.add_page()
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
if not add_info_page:
pdf.set_font(font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
if 'comments' in post and post['comments']:
comments_list = post['comments']
@ -80,17 +222,17 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.set_font(font_family, '', 10)
pdf.write(8, "Comment by: ")
if user is not None:
pdf.set_font(default_font_family, 'B', 10)
pdf.set_font(font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(default_font_family, '', 10)
pdf.set_font(font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.set_font(font_family, '', 11)
pdf.multi_cell(w=0, h=7, txt=body)
if comment_index < len(comments_list) - 1:
@ -98,7 +240,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
elif 'content' in post:
pdf.set_font(default_font_family, '', 12)
pdf.set_font(font_family, '', 12)
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
try:

View File

@ -7,7 +7,7 @@ import sys
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QMessageBox, QAbstractItemView, QLabel
QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox
)
# --- Local Application Imports ---
@ -27,6 +27,11 @@ class UpdateCheckDialog(QDialog):
self.user_data_path = user_data_path
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
self._default_checkbox_tooltip = (
"If checked, the settings 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._load_profiles()
self._retranslate_ui()
@ -56,8 +61,16 @@ class UpdateCheckDialog(QDialog):
self.list_widget = QListWidget()
# No selection mode, we only care about checkboxes
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
# Connect signal to handle checkbox state changes
self.list_widget.itemChanged.connect(self._handle_item_changed)
layout.addWidget(self.list_widget)
# --- 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 ---
button_layout = QHBoxLayout()
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.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
self.load_settings_checkbox.setText(self._tr("update_check_load_settings_checkbox", "Load profile settings into UI (Edit Settings)"))
def _load_profiles(self):
"""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.select_all_button.setEnabled(False)
self.deselect_all_button.setEnabled(False)
self.load_settings_checkbox.setEnabled(False)
def _toggle_all_checkboxes(self):
"""Handles Select All and Deselect All button clicks."""
sender = self.sender()
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
# Block signals to prevent triggering _handle_item_changed repeatedly
self.list_widget.blockSignals(True)
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable:
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):
"""Handles the 'Check Selected' button click."""
@ -177,3 +219,8 @@ class UpdateCheckDialog(QDialog):
def get_selected_profiles(self):
"""Returns the list of profile data selected by the user."""
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,6 +163,7 @@ class DownloaderApp (QWidget ):
self.is_ready_to_download_batch_update = False
self.is_finishing = False
self.finish_lock = threading.Lock()
self.add_info_in_pdf_setting = False
saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
if saved_res != "Auto":
@ -339,7 +340,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v6.7.0")
self.setWindowTitle("Kemono Downloader v7.8.0")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@ -656,6 +657,7 @@ class DownloaderApp (QWidget ):
settings['more_filter_scope'] = self.more_filter_scope
settings['text_export_format'] = self.text_export_format
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_limit'] = self.keep_duplicates_limit
@ -936,7 +938,7 @@ class DownloaderApp (QWidget ):
domain_override = download_commands.get('domain_override')
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['domain_override'] = domain_override
@ -2938,33 +2940,59 @@ class DownloaderApp (QWidget ):
return True
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:
# Determine initial values for the dialog based on current state or defaults
current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT
current_format = self.text_export_format or 'pdf'
# Initialize the dialog with the 'add_info_checked' parameter
dialog = MoreOptionsDialog(
self,
current_scope=current_scope,
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:
# --- User clicked OK: Update settings ---
self.more_filter_scope = dialog.get_selected_scope()
self.text_export_format = dialog.get_selected_format()
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')
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:
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}")
# --- Handle Option Conflicts (Multithreading) ---
# PDF generation requires the main thread or careful management, so we disable multithreading
if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode)
if is_any_pdf_mode:
self.use_multithreading_checkbox.setChecked(False)
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'):
self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting)
if self.single_pdf_setting:
@ -2975,23 +3003,39 @@ class DownloaderApp (QWidget ):
if self.single_pdf_setting:
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()}")
self.log_signal.emit(f" Single PDF setting: {'Enabled' if self.single_pdf_setting else 'Disabled'}")
# --- Logging ---
self.log_signal.emit(f" 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
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:
# --- User clicked Cancel: Revert to default ---
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:
self.radio_more.setText("More")
self.more_filter_scope = None
self.single_pdf_setting = False
self.add_info_in_pdf_setting = False # Reset setting
# Restore enabled states for options that PDF mode might have disabled
if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode()
self._update_multithreading_for_date_mode() # Re-check manga logic
if hasattr(self, 'use_subfolders_checkbox'):
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 ):
global KNOWN_NAMES
selected_items =self .character_list .selectedItems ()
@ -3134,54 +3178,65 @@ class DownloaderApp (QWidget ):
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 ""
_, _, post_id = extract_post_info(url_text)
is_single_post = bool(post_id)
service, _, post_id = extract_post_info(url_text)
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
new_style = ""
if is_single_post:
# Cycle through a limited set of styles suitable for single posts
if current_style == STYLE_POST_TITLE:
# ... (Cycle logic for single posts) ...
if current_style == STYLE_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
elif current_style == STYLE_ORIGINAL_NAME:
elif current_style == STYLE_ORIGINAL_NAME:
new_style = STYLE_POST_ID
elif current_style == STYLE_POST_ID:
elif current_style == STYLE_POST_ID:
new_style = STYLE_CUSTOM
elif current_style == STYLE_CUSTOM:
elif current_style == STYLE_CUSTOM:
new_style = STYLE_POST_TITLE
else: # Fallback for any other style
else:
new_style = STYLE_POST_TITLE
else:
# Original cycling logic for creator feeds
if current_style ==STYLE_POST_TITLE :
new_style =STYLE_ORIGINAL_NAME
elif current_style ==STYLE_ORIGINAL_NAME :
new_style =STYLE_DATE_POST_TITLE
elif current_style ==STYLE_DATE_POST_TITLE :
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
new_style =STYLE_DATE_BASED
elif current_style ==STYLE_DATE_BASED :
new_style =STYLE_POST_ID
elif current_style ==STYLE_POST_ID:
new_style =STYLE_CUSTOM # <-- CHANGE THIS
elif current_style == STYLE_CUSTOM: # <-- ADD THIS
new_style = STYLE_POST_TITLE # <-- ADD THIS
else :
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
new_style =STYLE_POST_TITLE
# ... (Cycle logic for creators) ...
if current_style == STYLE_POST_TITLE:
new_style = STYLE_ORIGINAL_NAME
elif current_style == STYLE_ORIGINAL_NAME:
new_style = STYLE_DATE_POST_TITLE
elif current_style == STYLE_DATE_POST_TITLE:
new_style = STYLE_POST_TITLE_GLOBAL_NUMBERING
elif current_style == STYLE_POST_TITLE_GLOBAL_NUMBERING:
new_style = STYLE_DATE_BASED
elif current_style == STYLE_DATE_BASED:
new_style = STYLE_POST_ID
elif current_style == STYLE_POST_ID:
new_style = STYLE_CUSTOM
elif current_style == STYLE_CUSTOM:
new_style = STYLE_POST_TITLE
else:
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 ):
if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
@ -3238,7 +3293,6 @@ class DownloaderApp (QWidget ):
is_discord_url = (service == 'discord')
if is_discord_url:
# When a discord URL is detected, disable incompatible options
if self.manga_mode_checkbox:
self.manga_mode_checkbox.setEnabled(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.end_page_input: self.end_page_input.setEnabled(False)
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_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 ()
# The rest of the original function continues from here...
_ ,_ ,post_id =extract_post_info (url_text )
is_creator_feed =not post_id if url_text else False
@ -3280,7 +3332,6 @@ class DownloaderApp (QWidget ):
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 ))
# --- MODIFIED: Added check for is_discord_url ---
if not is_discord_url:
self .update_page_range_enabled_state ()
@ -3318,7 +3369,20 @@ class DownloaderApp (QWidget ):
def _show_custom_rename_dialog(self):
"""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:
self.custom_manga_filename_format = dialog.get_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._update_manga_filename_style_button_text()
def update_multithreading_label (self ,text ):
if self .use_multithreading_checkbox .isChecked ():
base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading")
@ -3468,13 +3531,53 @@ class DownloaderApp (QWidget ):
url_text = self.link_input.text().strip()
service, _, _ = extract_post_info(url_text)
# --- DEFINE VARIABLES FIRST ---
is_deviantart = (service == 'deviantart')
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'):
self.advanced_settings_widget.setVisible(not is_simpcity)
if hasattr(self, 'simpcity_settings_widget'):
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
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.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)
if hasattr(self, 'discord_message_limit_input'):
self.discord_message_limit_input.setVisible(is_official_discord_url)
@ -3534,6 +3628,11 @@ class DownloaderApp (QWidget ):
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 self.main_log_output: self.main_log_output.clear()
if self.external_log_output: self.external_log_output.clear()
@ -3548,6 +3647,28 @@ class DownloaderApp (QWidget ):
if not direct_api_url:
api_url_text = self.link_input.text().strip().lower()
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': {
'name': 'AllPornComic',
'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.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:
# 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({
'url': url,
'name': f"{name} link from batch",
'type': 'post'
'name': f"{name} batch: {folder_name_to_use}",
'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:
self._process_next_favorite_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:
self._start_download_of_fetched_posts()
return True
@ -3649,7 +3824,6 @@ class DownloaderApp (QWidget ):
self.is_finishing = False
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:
self.permanently_failed_files_for_dialog.clear()
@ -4321,7 +4495,8 @@ class DownloaderApp (QWidget ):
'start_offset': start_offset_for_restore,
'fetch_first': fetch_first_enabled,
'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
@ -4734,6 +4909,7 @@ class DownloaderApp (QWidget ):
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
'add_info_in_pdf': self.add_info_in_pdf_setting,
}
# 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,
'override_output_dir': None,
'processed_post_ids': [],
'add_info_in_pdf': False,
}
for item in self.fetched_posts_for_batch_update:
@ -4784,18 +4961,33 @@ class DownloaderApp (QWidget ):
# --- THIS IS THE NEW, CORRECTED LOGIC ---
full_profile_data = item.get('profile_data', {})
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()
# Overwrite with any settings saved in the profile
# This is where {"filter_mode": "video"} from Maplestar.json is applied
args_for_this_worker.update(saved_settings)
# Add all the live runtime arguments
args_for_this_worker.update(live_runtime_args)
# Check the override flag we set in _show_empty_popup
if getattr(self, 'override_update_profile_settings', False):
# 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)
# 4. Manually parse values from the constructed args
# Set post-specific data
@ -4936,6 +5128,7 @@ class DownloaderApp (QWidget ):
self.more_filter_scope = settings.get('more_filter_scope')
self.text_export_format = settings.get('text_export_format', 'pdf')
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:
from .dialogs.MoreOptionsDialog import MoreOptionsDialog
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)...")
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)
def _add_to_history_candidates(self, history_data):
@ -5533,6 +5732,10 @@ class DownloaderApp (QWidget ):
if not self.finish_lock.acquire(blocking=False):
return
# --- Flag to track if we still hold the lock ---
lock_held = True
# ----------------------------------------------------
try:
if self.is_finishing:
return
@ -5554,16 +5757,21 @@ class DownloaderApp (QWidget ):
self.log_signal.emit("🏁 Download of current item complete.")
# --- QUEUE PROCESSING BLOCK ---
if self.is_processing_favorites_queue and self.favorite_download_queue:
self.log_signal.emit("✅ Item finished. Processing next in queue...")
if self.download_thread and isinstance(self.download_thread, QThread):
self.download_thread.deleteLater()
self.download_thread = None # This is the crucial line
self.download_thread = None
self.is_finishing = False
# FIX: Manual release + update flag
self.finish_lock.release()
lock_held = False
self._process_next_favorite_download()
return
# ---------------------------------------------------------
if self.is_processing_favorites_queue:
self.is_processing_favorites_queue = False
@ -5618,7 +5826,6 @@ class DownloaderApp (QWidget ):
if self.download_thread:
if isinstance(self.download_thread, QThread):
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, '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)
@ -5644,6 +5851,7 @@ class DownloaderApp (QWidget ):
)
self.file_progress_label.setText("")
# --- RETRY PROMPT BLOCK ---
if not cancelled_by_user and self.retryable_failed_files_info:
num_failed = len(self.retryable_failed_files_info)
reply = QMessageBox.question(self, "Retry Failed Downloads?",
@ -5652,7 +5860,11 @@ class DownloaderApp (QWidget ):
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes:
self.is_finishing = False
# FIX: Manual release + update flag
self.finish_lock.release()
lock_held = False
self._start_failed_files_retry_session()
return
else:
@ -5663,9 +5875,9 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False
self.retryable_failed_files_info.clear()
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:
num_files_to_retry = len(self.permanently_failed_files_for_dialog)
self.log_signal.emit("=" * 40)
@ -5680,7 +5892,6 @@ class DownloaderApp (QWidget ):
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:
self._execute_post_download_action()
@ -5688,8 +5899,12 @@ class DownloaderApp (QWidget ):
self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False
self.active_update_profile = None
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):
"""Checks the settings and performs the chosen action after downloads complete."""
@ -5845,6 +6060,7 @@ class DownloaderApp (QWidget ):
'domain_override': domain_override_command,
'sfp_threshold': sfp_threshold_command,
'handle_unknown_mode': handle_unknown_command,
'filter_mode':self .get_filter_mode (),
'skip_zip':self .skip_zip_checkbox .isChecked (),
'use_subfolders':self .use_subfolders_checkbox .isChecked (),
@ -5868,13 +6084,11 @@ class DownloaderApp (QWidget ):
'target_post_id_from_initial_url':None ,
'custom_folder_name':None ,
'num_file_threads':1 ,
# --- START: ADDED COOKIE FIX ---
'add_info_in_pdf': self.add_info_in_pdf_setting,
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir,
# --- END: ADDED COOKIE FIX ---
'manga_date_file_counter_ref':None ,
}
@ -6576,14 +6790,30 @@ class DownloaderApp (QWidget ):
return
dialog = EmptyPopupDialog(self.user_data_path, self)
if dialog.exec_() == QDialog.Accepted:
# --- NEW BATCH UPDATE LOGIC ---
# --- START OF MODIFICATION ---
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
self.active_update_profiles_list = dialog.update_profiles_list
# --- NEW LOGIC: Check if user wants to load settings into UI ---
load_settings_requested = getattr(dialog, 'load_settings_into_ui_requested', False)
self.override_update_profile_settings = load_settings_requested
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.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)
# --- Original logic for adding creators to queue ---
self._start_batch_update_check(self.active_update_profiles_list)
# --- END OF MODIFICATION ---
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None # Ensure single update mode is off
self.favorite_download_queue.clear()
@ -6830,11 +7060,19 @@ class DownloaderApp (QWidget ):
main_download_dir = self.dir_input.text().strip()
should_create_artist_folder = False
# --- Check for popup selection scope ---
if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS:
should_create_artist_folder = True
# --- Check for global "Artist Folders" scope ---
elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS:
should_create_artist_folder = True
# --- NEW: Check for forced folder flag from batch ---
if self.current_processing_favorite_item_info.get('force_artist_folder'):
should_create_artist_folder = True
# ---------------------------------------------------
if should_create_artist_folder and main_download_dir:
folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
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()
# --- DeviantArt Check ---
if 'deviantart.com' in stripped_url.lower() or 'fav.me' in stripped_url.lower():
# This MUST return 'deviantart' as the first element
return 'deviantart', 'placeholder_user', 'placeholder_id' # ----------------------
# --- Rule34Video Check ---
rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
if rule34video_match:

View File

@ -307,14 +307,18 @@ def setup_ui(main_app):
simpcity_settings_label = QLabel("⚙️ SimpCity Download Options:")
simpcity_settings_layout.addWidget(simpcity_settings_label)
# Checkbox row
# Checkbox row
simpcity_checkboxes_layout = QHBoxLayout()
main_app.simpcity_dl_images_cb = QCheckBox("Download Images")
main_app.simpcity_dl_images_cb.setChecked(True) # Checked by default
main_app.simpcity_dl_pixeldrain_cb = QCheckBox("Download Pixeldrain")
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile")
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_images_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_pixeldrain_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_cb)
@ -324,7 +328,6 @@ def setup_ui(main_app):
simpcity_settings_layout.addLayout(simpcity_checkboxes_layout)
# --- START NEW CODE ---
# Create the second, dedicated set of cookie controls for SimpCity
simpcity_cookie_layout = QHBoxLayout()
simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
simpcity_cookie_label = QLabel("Cookie:")

View File

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