diff --git a/src/core/Hentai2read_client.py b/src/core/Hentai2read_client.py index e1b34a7..e3e57e8 100644 --- a/src/core/Hentai2read_client.py +++ b/src/core/Hentai2read_client.py @@ -10,10 +10,9 @@ import queue def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func): """ Orchestrates the download process using a producer-consumer model. - The main thread scrapes image URLs and puts them in a queue. - A pool of worker threads consumes from the queue to download images concurrently. """ scraper = cloudscraper.create_scraper() + all_failed_files = [] # Track all failures across chapters try: progress_callback(" [Hentai2Read] Scraping series page for all metadata...") @@ -39,8 +38,7 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p final_save_path = os.path.join(output_dir, series_folder, chapter_folder) os.makedirs(final_save_path, exist_ok=True) - # This function now scrapes and downloads simultaneously - dl_count, skip_count = _process_and_download_chapter( + dl_count, skip_count, chapter_failures = _process_and_download_chapter( chapter_url=chapter['url'], save_path=final_save_path, scraper=scraper, @@ -51,9 +49,22 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p total_downloaded_count += dl_count total_skipped_count += skip_count + if chapter_failures: + all_failed_files.extend(chapter_failures) + overall_progress_callback(total_chapters, idx + 1) if check_pause_func(): break + # --- FINAL SUMMARY OF FAILURES --- + if all_failed_files: + progress_callback("\n" + "="*40) + progress_callback(f"❌ SUMMARY: {len(all_failed_files)} files failed permanently after 10 retries:") + for fail_msg in all_failed_files: + progress_callback(f" • {fail_msg}") + progress_callback("="*40 + "\n") + else: + progress_callback("\n✅ All chapters processed successfully with no permanent failures.") + return total_downloaded_count, total_skipped_count except Exception as e: @@ -63,9 +74,8 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p def _get_series_metadata(start_url, progress_callback, scraper): """ Scrapes the main series page to get the Artist Name, Series Title, and chapter list. - Includes a retry mechanism for the initial connection. """ - max_retries = 4 # Total number of attempts (1 initial + 3 retries) + max_retries = 4 last_exception = None soup = None @@ -77,8 +87,6 @@ def _get_series_metadata(start_url, progress_callback, scraper): response = scraper.get(start_url, timeout=30) response.raise_for_status() soup = BeautifulSoup(response.text, 'html.parser') - - # If successful, clear exception and break the loop last_exception = None break @@ -86,8 +94,8 @@ def _get_series_metadata(start_url, progress_callback, scraper): last_exception = e progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}") if attempt < max_retries - 1: - time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s - continue # Try again + time.sleep(2 * (attempt + 1)) + continue if last_exception: progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}") @@ -96,23 +104,36 @@ def _get_series_metadata(start_url, progress_callback, scraper): try: series_title = "Unknown Series" artist_name = None - metadata_list = soup.select_one("ul.list.list-simple-mini") - - if metadata_list: - first_li = metadata_list.find('li', recursive=False) - if first_li and not first_li.find('a'): - series_title = first_li.get_text(strip=True) + # 1. Try fetching Title + title_tag = soup.select_one("h3.block-title a") + if title_tag: + series_title = title_tag.get_text(strip=True) + else: + meta_title = soup.select_one("meta[property='og:title']") + if meta_title: + series_title = meta_title.get("content", "Unknown Series").replace(" - Hentai2Read", "") + + # 2. Try fetching Artist + metadata_list = soup.select_one("ul.list.list-simple-mini") + if metadata_list: for b_tag in metadata_list.find_all('b'): label = b_tag.get_text(strip=True) - if label in ("Artist", "Author"): + if "Artist" in label or "Author" in label: a_tag = b_tag.find_next_sibling('a') if a_tag: artist_name = a_tag.get_text(strip=True) - if label == "Artist": - break + break - top_level_folder_name = artist_name if artist_name else series_title + if not artist_name: + artist_link = soup.find('a', href=re.compile(r'/hentai-list/artist/')) + if artist_link: + artist_name = artist_link.get_text(strip=True) + + if artist_name: + top_level_folder_name = f"{artist_name} - {series_title}" + else: + top_level_folder_name = series_title chapter_links = soup.select("div.media a.pull-left.font-w600") if not chapter_links: @@ -124,7 +145,7 @@ def _get_series_metadata(start_url, progress_callback, scraper): ] chapters_to_process.reverse() - progress_callback(f" [Hentai2Read] ✅ Found Artist/Series: '{top_level_folder_name}'") + progress_callback(f" [Hentai2Read] ✅ Found Metadata: '{top_level_folder_name}'") progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.") return top_level_folder_name, chapters_to_process @@ -136,69 +157,102 @@ def _get_series_metadata(start_url, progress_callback, scraper): def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func): """ Uses a producer-consumer pattern to download a chapter. - The main thread (producer) scrapes URLs one by one. - Worker threads (consumers) download the URLs as they are found. + Includes RETRY LOGIC and ACTIVE LOGGING. """ task_queue = queue.Queue() num_download_threads = 8 download_stats = {'downloaded': 0, 'skipped': 0} + failed_files_list = [] def downloader_worker(): - """The function that each download thread will run.""" worker_scraper = cloudscraper.create_scraper() while True: - try: - # Get a task from the queue - task = task_queue.get() - # The sentinel value to signal the end - if task is None: - break - - filepath, img_url = task - if os.path.exists(filepath): - progress_callback(f" -> Skip: '{os.path.basename(filepath)}'") - download_stats['skipped'] += 1 - else: - progress_callback(f" Downloading: '{os.path.basename(filepath)}'...") + task = task_queue.get() + if task is None: + task_queue.task_done() + break + + filepath, img_url = task + filename = os.path.basename(filepath) + + if os.path.exists(filepath): + # We log skips to show it's checking files + progress_callback(f" -> Skip (Exists): '{filename}'") + download_stats['skipped'] += 1 + task_queue.task_done() + continue + + # --- RETRY LOGIC START --- + success = False + # UNCOMMENTED: Log the start of download so you see activity + progress_callback(f" Downloading: '{filename}'...") + + for attempt in range(10): # Try 10 times + try: + if attempt > 0: + progress_callback(f" ⚠️ Retrying '{filename}' (Attempt {attempt+1}/10)...") + time.sleep(2) + response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url}) response.raise_for_status() + with open(filepath, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) + download_stats['downloaded'] += 1 - except Exception as e: - progress_callback(f" ❌ Download failed for task. Error: {e}") - download_stats['skipped'] += 1 - finally: - task_queue.task_done() + success = True + # UNCOMMENTED: Log success + progress_callback(f" ✅ Downloaded: '{filename}'") + break + + except Exception as e: + if attempt == 9: + progress_callback(f" ❌ Failed '{filename}' after 10 attempts: {e}") + + if not success: + failed_files_list.append(f"{filename} (Chapter: {os.path.basename(save_path)})") + # Clean up empty file if failed + if os.path.exists(filepath): + try: + os.remove(filepath) + except OSError: pass + + task_queue.task_done() executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader') for _ in range(num_download_threads): executor.submit(downloader_worker) page_number = 1 + progress_callback(" [Hentai2Read] Scanning pages...") # Initial log + while True: if check_pause_func(): break - if page_number > 300: # Safety break + if page_number > 300: progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.") break + # Log occasionally to show scanning is alive + if page_number % 10 == 0: + progress_callback(f" [Hentai2Read] Scanned {page_number} pages so far...") + page_url_to_check = f"{chapter_url}{page_number}/" try: page_response = None page_last_exception = None - for page_attempt in range(3): # 3 attempts for sub-pages + for page_attempt in range(3): try: page_response = scraper.get(page_url_to_check, timeout=30) page_last_exception = None break except Exception as e: page_last_exception = e - time.sleep(1) # Short delay for page scraping retries + time.sleep(1) if page_last_exception: - raise page_last_exception # Give up after 3 tries + raise page_last_exception if page_response.history or page_response.status_code != 200: progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.") @@ -209,7 +263,7 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call img_src = img_tag.get("src") if img_tag else None if not img_tag or img_src == "https://static.hentai.direct/hentai": - progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).") + progress_callback(f" [Hentai2Read] End of chapter detected (Last page reached at {page_number}).") break normalized_img_src = urljoin(page_response.url, img_src) @@ -220,15 +274,19 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call task_queue.put((filepath, normalized_img_src)) page_number += 1 - time.sleep(0.1) # Small delay between scraping pages + time.sleep(0.1) except Exception as e: progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}") break + # Signal workers to exit for _ in range(num_download_threads): task_queue.put(None) + # Wait for all tasks to complete + task_queue.join() executor.shutdown(wait=True) - progress_callback(f" Found and processed {page_number - 1} images for this chapter.") - return download_stats['downloaded'], download_stats['skipped'] \ No newline at end of file + progress_callback(f" Chapter complete. Processed {page_number - 1} images.") + + return download_stats['downloaded'], download_stats['skipped'], failed_files_list \ No newline at end of file diff --git a/src/core/api_client.py b/src/core/api_client.py index 0be3ed5..41d0883 100644 --- a/src/core/api_client.py +++ b/src/core/api_client.py @@ -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).") + logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).") \ No newline at end of file diff --git a/src/core/deviantart_client.py b/src/core/deviantart_client.py new file mode 100644 index 0000000..22d9368 --- /dev/null +++ b/src/core/deviantart_client.py @@ -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 \ No newline at end of file diff --git a/src/core/workers.py b/src/core/workers.py index 2d122c2..d489cc8 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -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) + base, ext = os.path.splitext(final_save_path) + counter = 1 while os.path.exists(final_save_path): final_save_path = f"{base}_{counter}{ext}" counter += 1 + # --- PDF Generation --- if file_extension == 'pdf': - if FPDF: - self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...") - pdf = PDF() - base_path = self.project_root_dir - font_path = "" - bold_font_path = "" - - if base_path: - font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf') - bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf') - - try: - if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") - if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}") - pdf.add_font('DejaVu', '', font_path, uni=True) - pdf.add_font('DejaVu', 'B', bold_font_path, uni=True) - default_font_family = 'DejaVu' - except Exception as font_error: - self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") - default_font_family = 'Arial' - - pdf.add_page() - pdf.set_font(default_font_family, 'B', 16) - pdf.multi_cell(0, 10, post_title) - pdf.ln(10) - - if self.text_only_scope == 'comments': - if not comments_data: - self.logger(" -> Skip PDF Creation: No comments to process.") - result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) - self._emit_signal('worker_finished', result_tuple) - return result_tuple - for i, comment in enumerate(comments_data): - user = comment.get('commenter_name', 'Unknown User') - timestamp = comment.get('published', 'No Date') - body = strip_html_tags(comment.get('content', '')) - pdf.set_font(default_font_family, '', 10) - pdf.write(8, "Comment by: ") - pdf.set_font(default_font_family, 'B', 10) - pdf.write(8, user) - pdf.set_font(default_font_family, '', 10) - pdf.write(8, f" on {timestamp}") - pdf.ln(10) - pdf.set_font(default_font_family, '', 11) - pdf.multi_cell(0, 7, body) - if i < len(comments_data) - 1: - pdf.ln(5) - pdf.cell(0, 0, '', border='T') - pdf.ln(5) - else: - pdf.set_font(default_font_family, '', 12) - pdf.multi_cell(0, 7, cleaned_text) - - pdf.output(final_save_path) + # Font setup + font_path = "" + if self.project_root_dir: + font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + + # Add content specific fields for the generator + if self.text_only_scope == 'comments': + common_content_data['comments_list_for_pdf'] = comments_data else: - self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.") - final_save_path = os.path.splitext(final_save_path)[0] + ".txt" - with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text) - + common_content_data['content_text_for_pdf'] = cleaned_text + + # Call the centralized function + success = create_individual_pdf( + post_data=common_content_data, + output_filename=final_save_path, + font_path=font_path, + add_info_page=self.add_info_in_pdf, # <--- NEW PARAMETER + logger=self.logger + ) + + if not success: + raise Exception("PDF generation failed (check logs)") + + # --- DOCX Generation --- elif file_extension == 'docx': if Document: self.logger(f" Converting to DOCX...") document = Document() + # Add simple header info if desired, or keep pure text + if self.add_info_in_pdf: + document.add_heading(post_title, 0) + document.add_paragraph(f"Date: {common_content_data['published']}") + document.add_paragraph(f"Creator: {common_content_data['creator_name']}") + document.add_paragraph(f"URL: {common_content_data['original_link']}") + document.add_page_break() + document.add_paragraph(cleaned_text) document.save(final_save_path) else: @@ -1452,9 +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)") diff --git a/src/ui/classes/deviantart_downloader_thread.py b/src/ui/classes/deviantart_downloader_thread.py new file mode 100644 index 0000000..c66a75c --- /dev/null +++ b/src/ui/classes/deviantart_downloader_thread.py @@ -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 \ No newline at end of file diff --git a/src/ui/classes/downloader_factory.py b/src/ui/classes/downloader_factory.py index da1a929..c24fa7f 100644 --- a/src/ui/classes/downloader_factory.py +++ b/src/ui/classes/downloader_factory.py @@ -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 diff --git a/src/ui/classes/simp_city_downloader_thread.py b/src/ui/classes/simp_city_downloader_thread.py index 78ab0e8..82bd0ad 100644 --- a/src/ui/classes/simp_city_downloader_thread.py +++ b/src/ui/classes/simp_city_downloader_thread.py @@ -254,6 +254,7 @@ class SimpCityDownloadThread(QThread): self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked() self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked() self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked() + self.should_dl_images = self.parent_app.simpcity_dl_images_cb.isChecked() self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked() self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked() @@ -288,8 +289,10 @@ class SimpCityDownloadThread(QThread): enriched_jobs = self._get_enriched_jobs(jobs) if enriched_jobs: for job in enriched_jobs: - if job['type'] == 'image': self.image_queue.put(job) - else: self.service_queue.put(job) + if job['type'] == 'image': + if self.should_dl_images: self.image_queue.put(job) + else: self.service_queue.put(job) + else: base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/') page_counter = 1; end_of_thread = False; MAX_RETRIES = 3 @@ -347,11 +350,14 @@ class SimpCityDownloadThread(QThread): # This can happen if all new_jobs were e.g. pixeldrain and it's disabled self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.") end_of_thread = True + else: for job in enriched_jobs: self.processed_job_urls.add(job.get('url')) - if job['type'] == 'image': self.image_queue.put(job) + if job['type'] == 'image': + if self.should_dl_images: self.image_queue.put(job) else: self.service_queue.put(job) + page_fetch_successful = True; break except requests.exceptions.HTTPError as e: if e.response.status_code in [403, 404]: diff --git a/src/ui/dialogs/CustomFilenameDialog.py b/src/ui/dialogs/CustomFilenameDialog.py index 9e0756d..b345686 100644 --- a/src/ui/dialogs/CustomFilenameDialog.py +++ b/src/ui/dialogs/CustomFilenameDialog.py @@ -7,7 +7,6 @@ from PyQt5.QtCore import Qt class CustomFilenameDialog(QDialog): """A dialog for creating a custom filename format string.""" - # --- REPLACE THE 'AVAILABLE_KEYS' LIST WITH THIS DICTIONARY --- DISPLAY_KEY_MAP = { "PostID": "id", "CreatorName": "creator_name", @@ -19,7 +18,10 @@ class CustomFilenameDialog(QDialog): "name": "name" } - def __init__(self, current_format, current_date_format, parent=None): + # STRICT LIST: Only these three will be clickable for DeviantArt + DA_ALLOWED_KEYS = ["creator_name", "title", "published"] + + def __init__(self, current_format, current_date_format, parent=None, is_deviantart=False): super().__init__(parent) self.setWindowTitle("Custom Filename Format") self.setMinimumWidth(500) @@ -31,9 +33,11 @@ class CustomFilenameDialog(QDialog): layout = QVBoxLayout(self) # --- Description --- - description_label = QLabel( - "Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format." - ) + desc_text = "Create a filename format using placeholders. The date/time values will be automatically formatted." + if is_deviantart: + desc_text += "\n\n(DeviantArt Mode: Only Creator Name, Title, and Upload Date are available. Other buttons are disabled.)" + + description_label = QLabel(desc_text) description_label.setWordWrap(True) layout.addWidget(description_label) @@ -42,15 +46,20 @@ class CustomFilenameDialog(QDialog): layout.addWidget(format_label) self.format_input = QLineEdit(self) self.format_input.setText(self.current_format) - self.format_input.setPlaceholderText("e.g., {published} {title} {id}") + + if is_deviantart: + self.format_input.setPlaceholderText("e.g., {published} {title} {creator_name}") + else: + self.format_input.setPlaceholderText("e.g., {published} {title} {id}") + layout.addWidget(self.format_input) # --- Date Format Input --- - date_format_label = QLabel("Date Format (for {added}, {published}, {edited}):") + date_format_label = QLabel("Date Format (for {published}):") layout.addWidget(date_format_label) self.date_format_input = QLineEdit(self) self.date_format_input.setText(self.current_date_format) - self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD or DD-MM-YYYY") + self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD") layout.addWidget(self.date_format_input) # --- Available Keys Display --- @@ -62,7 +71,20 @@ class CustomFilenameDialog(QDialog): for display_key, internal_key in self.DISPLAY_KEY_MAP.items(): key_button = QPushButton(f"{{{display_key}}}") - # Use a lambda to pass the correct internal key when the button is clicked + +# --- DeviantArt Logic --- + if is_deviantart: + if internal_key in self.DA_ALLOWED_KEYS: + # Active buttons: Bold text, enabled + key_button.setStyleSheet("font-weight: bold; color: black;") + key_button.setEnabled(True) + else: + # Inactive buttons: Disabled (Cannot be clicked) + key_button.setEnabled(False) + key_button.setToolTip("Not available for DeviantArt") + # ------------------------ + + # Use a lambda to pass the correct internal key when clicked key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key)) keys_layout.addWidget(key_button) keys_layout.addStretch() @@ -81,9 +103,7 @@ class CustomFilenameDialog(QDialog): self.format_input.setFocus() def get_format_string(self): - """Returns the final format string from the input field.""" return self.format_input.text().strip() def get_date_format_string(self): - """Returns the date format string from its input field.""" - return self.date_format_input.text().strip() + return self.date_format_input.text().strip() \ No newline at end of file diff --git a/src/ui/dialogs/EmptyPopupDialog.py b/src/ui/dialogs/EmptyPopupDialog.py index de625d3..7ee603e 100644 --- a/src/ui/dialogs/EmptyPopupDialog.py +++ b/src/ui/dialogs/EmptyPopupDialog.py @@ -156,6 +156,9 @@ class EmptyPopupDialog (QDialog ): # --- MODIFIED: Store a list of profiles now --- self.update_profiles_list = None + # --- NEW: Flag to indicate if settings should load to UI --- + self.load_settings_into_ui_requested = False + # --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) --- self.update_profile_data = None self.update_creator_name = None @@ -341,6 +344,9 @@ class EmptyPopupDialog (QDialog ): if dialog.exec_() == QDialog.Accepted: # --- MODIFIED: Get a list of profiles now --- selected_profiles = dialog.get_selected_profiles() + # --- NEW: Get the checkbox state --- + self.load_settings_into_ui_requested = dialog.should_load_into_ui() + if selected_profiles: try: # --- MODIFIED: Store the list --- @@ -1052,4 +1058,4 @@ class EmptyPopupDialog (QDialog ): else : if unique_key in self .globally_selected_creators : del self .globally_selected_creators [unique_key ] - self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators )) \ No newline at end of file + self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators )) diff --git a/src/ui/dialogs/MoreOptionsDialog.py b/src/ui/dialogs/MoreOptionsDialog.py index ec34564..c632caa 100644 --- a/src/ui/dialogs/MoreOptionsDialog.py +++ b/src/ui/dialogs/MoreOptionsDialog.py @@ -11,17 +11,16 @@ class MoreOptionsDialog(QDialog): SCOPE_CONTENT = "content" SCOPE_COMMENTS = "comments" - def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False): + def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False, add_info_checked=False): super().__init__(parent) self.parent_app = parent self.setWindowTitle("More Options") self.setMinimumWidth(350) - # ... (Layout and other widgets remain the same) ... - layout = QVBoxLayout(self) self.description_label = QLabel("Please choose the scope for the action:") layout.addWidget(self.description_label) + self.radio_button_group = QButtonGroup(self) self.radio_content = QRadioButton("Description/Content") self.radio_comments = QRadioButton("Comments") @@ -50,14 +49,20 @@ class MoreOptionsDialog(QDialog): export_layout.addStretch() layout.addLayout(export_layout) - # --- UPDATED: Single PDF Checkbox --- + # --- Single PDF Checkbox --- self.single_pdf_checkbox = QCheckBox("Single PDF") self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.") self.single_pdf_checkbox.setChecked(single_pdf_checked) layout.addWidget(self.single_pdf_checkbox) - self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state) - self.update_single_pdf_checkbox_state(self.format_combo.currentText()) + # --- NEW: Add Info Checkbox --- + self.add_info_checkbox = QCheckBox("Add info in PDF") + self.add_info_checkbox.setToolTip("If checked, adds a first page with post details (Title, Date, Link, Creator, Tags, etc.).") + self.add_info_checkbox.setChecked(add_info_checked) + layout.addWidget(self.add_info_checkbox) + + self.format_combo.currentTextChanged.connect(self.update_checkbox_states) + self.update_checkbox_states(self.format_combo.currentText()) self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.button_box.accepted.connect(self.accept) @@ -65,12 +70,18 @@ class MoreOptionsDialog(QDialog): layout.addWidget(self.button_box) self.setLayout(layout) self._apply_theme() - def update_single_pdf_checkbox_state(self, text): - """Enable the Single PDF checkbox only if the format is PDF.""" + + def update_checkbox_states(self, text): + """Enable PDF-specific checkboxes only if the format is PDF.""" is_pdf = (text.upper() == "PDF") self.single_pdf_checkbox.setEnabled(is_pdf) + self.add_info_checkbox.setEnabled(is_pdf) + if not is_pdf: self.single_pdf_checkbox.setChecked(False) + # We don't uncheck add_info necessarily, just disable it, + # but unchecking is safer visually to imply "won't happen" + self.add_info_checkbox.setChecked(False) def get_selected_scope(self): if self.radio_comments.isChecked(): @@ -84,13 +95,14 @@ class MoreOptionsDialog(QDialog): """Returns the state of the Single PDF checkbox.""" return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled() + def get_add_info_state(self): + """Returns the state of the Add Info checkbox.""" + return self.add_info_checkbox.isChecked() and self.add_info_checkbox.isEnabled() + def _apply_theme(self): """Applies the current theme from the parent application.""" - if self.parent_app and self.parent_app.current_theme == "dark": - # Get the scale factor from the parent app + if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": scale = getattr(self.parent_app, 'scale_factor', 1) - # Call the imported function with the correct scale self.setStyleSheet(get_dark_theme(scale)) else: - # Explicitly set a blank stylesheet for light mode - self.setStyleSheet("") + self.setStyleSheet("") \ No newline at end of file diff --git a/src/ui/dialogs/SinglePDF.py b/src/ui/dialogs/SinglePDF.py index 5ef5478..f8cddca 100644 --- a/src/ui/dialogs/SinglePDF.py +++ b/src/ui/dialogs/SinglePDF.py @@ -4,24 +4,22 @@ try: from fpdf import FPDF FPDF_AVAILABLE = True - # --- FIX: Move the class definition inside the try block --- class PDF(FPDF): """Custom PDF class to handle headers and footers.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.font_family_main = 'Arial' + def header(self): pass def footer(self): self.set_y(-15) - if self.font_family: - self.set_font(self.font_family, '', 8) - else: - self.set_font('Arial', '', 8) + self.set_font(self.font_family_main, '', 8) self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C') except ImportError: FPDF_AVAILABLE = False - # If the import fails, FPDF and PDF will not be defined, - # but the program won't crash here. FPDF = None PDF = None @@ -31,12 +29,169 @@ def strip_html_tags(text): clean = re.compile('<.*?>') return re.sub(clean, '', text) -def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print): +def _setup_pdf_fonts(pdf, font_path, logger=print): + """Helper to setup fonts for the PDF instance.""" + bold_font_path = "" + default_font = 'Arial' + + if font_path: + bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf") + + try: + if font_path and os.path.exists(font_path): + pdf.add_font('DejaVu', '', font_path, uni=True) + default_font = 'DejaVu' + if os.path.exists(bold_font_path): + pdf.add_font('DejaVu', 'B', bold_font_path, uni=True) + else: + pdf.add_font('DejaVu', 'B', font_path, uni=True) + except Exception as font_error: + logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") + default_font = 'Arial' + + pdf.font_family_main = default_font + return default_font + +def add_metadata_page(pdf, post, font_family): + """Adds a dedicated metadata page to the PDF with clickable links.""" + pdf.add_page() + pdf.set_font(font_family, 'B', 16) + pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='C') + pdf.ln(10) + pdf.set_font(font_family, '', 11) + + def add_info_row(label, value, link_url=None): + if not value: return + + # Write Label (Bold) + pdf.set_font(font_family, 'B', 11) + pdf.write(8, f"{label}: ") + + # Write Value + if link_url: + # Styling for clickable link: Blue + Underline + pdf.set_text_color(0, 0, 255) + # Check if font supports underline style directly or just use 'U' + # FPDF standard allows 'U' in style string. + # We use 'U' combined with the font family. + # Note: DejaVu implementation in fpdf2 might handle 'U' automatically or ignore it depending on version, + # but setting text color indicates link clearly enough usually. + pdf.set_font(font_family, 'U', 11) + + # Pass the URL to the 'link' parameter + pdf.multi_cell(w=0, h=8, txt=str(value), link=link_url) + + # Reset styles + pdf.set_text_color(0, 0, 0) + pdf.set_font(font_family, '', 11) + else: + pdf.set_font(font_family, '', 11) + pdf.multi_cell(w=0, h=8, txt=str(value)) + + pdf.ln(2) + + date_str = post.get('published') or post.get('added') or 'Unknown' + add_info_row("Date Uploaded", date_str) + + creator = post.get('creator_name') or post.get('user') or 'Unknown' + add_info_row("Creator", creator) + + add_info_row("Service", post.get('service', 'Unknown')) + + link = post.get('original_link') + if not link and post.get('service') and post.get('user') and post.get('id'): + link = f"https://kemono.su/{post['service']}/user/{post['user']}/post/{post['id']}" + + # Pass 'link' as both the text value AND the URL target + add_info_row("Original Link", link, link_url=link) + + tags = post.get('tags') + if tags: + tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags) + add_info_row("Tags", tags_str) + + pdf.ln(10) + pdf.cell(0, 0, border='T') + pdf.ln(10) + +def create_individual_pdf(post_data, output_filename, font_path, add_info_page=False, add_comments=False, logger=print): """ - Creates a single, continuous PDF, correctly formatting both descriptions and comments. + Creates a PDF for a single post. + Supports optional metadata page and appending comments. """ if not FPDF_AVAILABLE: - logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2") + logger("❌ PDF Creation failed: 'fpdf2' library not installed.") + return False + + pdf = PDF() + font_family = _setup_pdf_fonts(pdf, font_path, logger) + + if add_info_page: + # add_metadata_page adds the page start itself + add_metadata_page(pdf, post_data, font_family) + # REMOVED: pdf.add_page() <-- This ensures content starts right below the line + else: + pdf.add_page() + + # Only add the Title header manually if we didn't add the info page + # (Because the info page already contains the title at the top) + if not add_info_page: + pdf.set_font(font_family, 'B', 16) + pdf.multi_cell(w=0, h=10, txt=post_data.get('title', 'Untitled Post'), align='L') + pdf.ln(5) + + content_text = post_data.get('content_text_for_pdf') + comments_list = post_data.get('comments_list_for_pdf') + + # 1. Write Content + if content_text: + pdf.set_font(font_family, '', 12) + pdf.multi_cell(w=0, h=7, txt=content_text) + pdf.ln(10) + + # 2. Write Comments (if enabled and present) + if comments_list and (add_comments or not content_text): + if add_comments and content_text: + pdf.add_page() + pdf.set_font(font_family, 'B', 14) + pdf.cell(0, 10, "Comments", ln=True) + pdf.ln(5) + + for i, comment in enumerate(comments_list): + user = comment.get('commenter_name', 'Unknown User') + timestamp = comment.get('published', 'No Date') + body = strip_html_tags(comment.get('content', '')) + + pdf.set_font(font_family, '', 10) + pdf.write(8, "Comment by: ") + pdf.set_font(font_family, 'B', 10) + pdf.write(8, str(user)) + + pdf.set_font(font_family, '', 10) + pdf.write(8, f" on {timestamp}") + pdf.ln(10) + + pdf.set_font(font_family, '', 11) + pdf.multi_cell(w=0, h=7, txt=body) + + if i < len(comments_list) - 1: + pdf.ln(3) + pdf.cell(w=0, h=0, border='T') + pdf.ln(3) + + try: + pdf.output(output_filename) + return True + except Exception as e: + logger(f"❌ Error saving PDF '{os.path.basename(output_filename)}': {e}") + return False + +def create_single_pdf_from_content(posts_data, output_filename, font_path, add_info_page=False, logger=print): + """ + Creates a single, continuous PDF from multiple posts. + """ + if not FPDF_AVAILABLE: + logger("❌ PDF Creation failed: 'fpdf2' library is not installed.") return False if not posts_data: @@ -44,34 +199,21 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge return False pdf = PDF() - default_font_family = 'DejaVu' + font_family = _setup_pdf_fonts(pdf, font_path, logger) - bold_font_path = "" - if font_path: - bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf") - - try: - if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") - if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}") - - pdf.add_font('DejaVu', '', font_path, uni=True) - pdf.add_font('DejaVu', 'B', bold_font_path, uni=True) - except Exception as font_error: - logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.") - default_font_family = 'Arial' - - pdf.add_page() - logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...") for i, post in enumerate(posts_data): - if i > 0: - # This ensures every post after the first gets its own page. + if add_info_page: + add_metadata_page(pdf, post, font_family) + # REMOVED: pdf.add_page() <-- This ensures content starts right below the line + else: pdf.add_page() - pdf.set_font(default_font_family, 'B', 16) - pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L') - pdf.ln(5) + if not add_info_page: + pdf.set_font(font_family, 'B', 16) + pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L') + pdf.ln(5) if 'comments' in post and post['comments']: comments_list = post['comments'] @@ -80,17 +222,17 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge timestamp = comment.get('published', 'No Date') body = strip_html_tags(comment.get('content', '')) - pdf.set_font(default_font_family, '', 10) + pdf.set_font(font_family, '', 10) pdf.write(8, "Comment by: ") if user is not None: - pdf.set_font(default_font_family, 'B', 10) + pdf.set_font(font_family, 'B', 10) pdf.write(8, str(user)) - pdf.set_font(default_font_family, '', 10) + pdf.set_font(font_family, '', 10) pdf.write(8, f" on {timestamp}") pdf.ln(10) - pdf.set_font(default_font_family, '', 11) + pdf.set_font(font_family, '', 11) pdf.multi_cell(w=0, h=7, txt=body) if comment_index < len(comments_list) - 1: @@ -98,7 +240,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge pdf.cell(w=0, h=0, border='T') pdf.ln(3) elif 'content' in post: - pdf.set_font(default_font_family, '', 12) + pdf.set_font(font_family, '', 12) pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content')) try: @@ -107,4 +249,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge return True except Exception as e: logger(f"❌ A critical error occurred while saving the final PDF: {e}") - return False + return False \ No newline at end of file diff --git a/src/ui/dialogs/UpdateCheckDialog.py b/src/ui/dialogs/UpdateCheckDialog.py index 7b2a20a..0eb3dd1 100644 --- a/src/ui/dialogs/UpdateCheckDialog.py +++ b/src/ui/dialogs/UpdateCheckDialog.py @@ -7,7 +7,7 @@ import sys from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, - QPushButton, QMessageBox, QAbstractItemView, QLabel + QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox ) # --- Local Application Imports --- @@ -26,6 +26,11 @@ class UpdateCheckDialog(QDialog): self.parent_app = parent_app_ref self.user_data_path = user_data_path self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...} + + self._default_checkbox_tooltip = ( + "If checked, the settings 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() @@ -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.""" @@ -176,4 +218,9 @@ class UpdateCheckDialog(QDialog): def get_selected_profiles(self): """Returns the list of profile data selected by the user.""" - return self.selected_profiles_list \ No newline at end of file + 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() diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 0baf03f..51476ea 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -163,7 +163,8 @@ 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": try: @@ -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,55 +3178,66 @@ class DownloaderApp (QWidget ): self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Renaming Mode is active for a creator feed).") - 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 - - 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 }'") + # ... (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) + def _handle_favorite_mode_toggle (self ,checked ): if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack : return @@ -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") @@ -3445,7 +3508,7 @@ class DownloaderApp (QWidget ): def _update_contextual_ui_elements(self, text=""): """Shows or hides UI elements based on the URL, like the Discord scope button.""" - + if 'allporncomic.com' in text.lower() and not hasattr(self, 'allcomic_warning_shown'): self.allcomic_warning_shown = False if 'allporncomic.com' in text.lower() and not self.allcomic_warning_shown: @@ -3468,13 +3531,53 @@ class DownloaderApp (QWidget ): url_text = self.link_input.text().strip() 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) @@ -3533,7 +3627,12 @@ class DownloaderApp (QWidget ): return get_theme_stylesheet(actual_scale) def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None): - + + 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() @@ -3547,7 +3646,29 @@ class DownloaderApp (QWidget ): if not direct_api_url: api_url_text = self.link_input.text().strip().lower() - batch_handlers = { + batch_handlers = { + 'kemono.cr': { + 'name': 'Kemono Batch', + 'txt_file': 'kemono.txt', + 'url_regex': r'https?://(?:www\.)?kemono\.(?:su|party|cr)/[^/\s]+/user/\d+(?:/post/\d+)?/?' + }, + 'kemono.su': { + 'name': 'Kemono Batch', + 'txt_file': 'kemono.txt', + 'url_regex': r'https?://(?:www\.)?kemono\.(?:su|party|cr)/[^/\s]+/user/\d+(?:/post/\d+)?/?' + }, + + 'coomer.st': { + 'name': 'Coomer Batch', + 'txt_file': 'coomer.txt', + 'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?' + }, + 'coomer.su': { + 'name': 'Coomer Batch', + 'txt_file': 'coomer.txt', + 'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?' + }, + 'allporncomic.com': { '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 + + # 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 --- + # --- 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) diff --git a/src/utils/network_utils.py b/src/utils/network_utils.py index 86c5a1c..416c3d7 100644 --- a/src/utils/network_utils.py +++ b/src/utils/network_utils.py @@ -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: diff --git a/src/utils/resolution.py b/src/utils/resolution.py index b0b485d..168c818 100644 --- a/src/utils/resolution.py +++ b/src/utils/resolution.py @@ -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:") diff --git a/structure.txt b/structure.txt index bde55bc..ae8578c 100644 --- a/structure.txt +++ b/structure.txt @@ -20,7 +20,6 @@ │ ├── DejaVuSansCondensed-BoldOblique.ttf │ ├── DejaVuSansCondensed-Oblique.ttf │ └── DejaVuSansCondensed.ttf -├── directory_tree.txt ├── main.py ├── src/ │ ├── __init__.py