mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-17 07:26:22 +00:00
Commit
This commit is contained in:
parent
67faea0992
commit
b5b6c1bc46
@ -10,10 +10,9 @@ import queue
|
||||
def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func):
|
||||
"""
|
||||
Orchestrates the download process using a producer-consumer model.
|
||||
The main thread scrapes image URLs and puts them in a queue.
|
||||
A pool of worker threads consumes from the queue to download images concurrently.
|
||||
"""
|
||||
scraper = cloudscraper.create_scraper()
|
||||
all_failed_files = [] # Track all failures across chapters
|
||||
|
||||
try:
|
||||
progress_callback(" [Hentai2Read] Scraping series page for all metadata...")
|
||||
@ -39,8 +38,7 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
|
||||
os.makedirs(final_save_path, exist_ok=True)
|
||||
|
||||
# This function now scrapes and downloads simultaneously
|
||||
dl_count, skip_count = _process_and_download_chapter(
|
||||
dl_count, skip_count, chapter_failures = _process_and_download_chapter(
|
||||
chapter_url=chapter['url'],
|
||||
save_path=final_save_path,
|
||||
scraper=scraper,
|
||||
@ -51,9 +49,22 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
total_downloaded_count += dl_count
|
||||
total_skipped_count += skip_count
|
||||
|
||||
if chapter_failures:
|
||||
all_failed_files.extend(chapter_failures)
|
||||
|
||||
overall_progress_callback(total_chapters, idx + 1)
|
||||
if check_pause_func(): break
|
||||
|
||||
# --- FINAL SUMMARY OF FAILURES ---
|
||||
if all_failed_files:
|
||||
progress_callback("\n" + "="*40)
|
||||
progress_callback(f"❌ SUMMARY: {len(all_failed_files)} files failed permanently after 10 retries:")
|
||||
for fail_msg in all_failed_files:
|
||||
progress_callback(f" • {fail_msg}")
|
||||
progress_callback("="*40 + "\n")
|
||||
else:
|
||||
progress_callback("\n✅ All chapters processed successfully with no permanent failures.")
|
||||
|
||||
return total_downloaded_count, total_skipped_count
|
||||
|
||||
except Exception as e:
|
||||
@ -63,9 +74,8 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
"""
|
||||
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
|
||||
Includes a retry mechanism for the initial connection.
|
||||
"""
|
||||
max_retries = 4 # Total number of attempts (1 initial + 3 retries)
|
||||
max_retries = 4
|
||||
last_exception = None
|
||||
soup = None
|
||||
|
||||
@ -77,8 +87,6 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
response = scraper.get(start_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# If successful, clear exception and break the loop
|
||||
last_exception = None
|
||||
break
|
||||
|
||||
@ -86,8 +94,8 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
last_exception = e
|
||||
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s
|
||||
continue # Try again
|
||||
time.sleep(2 * (attempt + 1))
|
||||
continue
|
||||
|
||||
if last_exception:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
|
||||
@ -96,23 +104,36 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
try:
|
||||
series_title = "Unknown Series"
|
||||
artist_name = None
|
||||
|
||||
# 1. Try fetching Title
|
||||
title_tag = soup.select_one("h3.block-title a")
|
||||
if title_tag:
|
||||
series_title = title_tag.get_text(strip=True)
|
||||
else:
|
||||
meta_title = soup.select_one("meta[property='og:title']")
|
||||
if meta_title:
|
||||
series_title = meta_title.get("content", "Unknown Series").replace(" - Hentai2Read", "")
|
||||
|
||||
# 2. Try fetching Artist
|
||||
metadata_list = soup.select_one("ul.list.list-simple-mini")
|
||||
|
||||
if metadata_list:
|
||||
first_li = metadata_list.find('li', recursive=False)
|
||||
if first_li and not first_li.find('a'):
|
||||
series_title = first_li.get_text(strip=True)
|
||||
|
||||
for b_tag in metadata_list.find_all('b'):
|
||||
label = b_tag.get_text(strip=True)
|
||||
if label in ("Artist", "Author"):
|
||||
if "Artist" in label or "Author" in label:
|
||||
a_tag = b_tag.find_next_sibling('a')
|
||||
if a_tag:
|
||||
artist_name = a_tag.get_text(strip=True)
|
||||
if label == "Artist":
|
||||
break
|
||||
|
||||
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,41 +157,68 @@ 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:
|
||||
task_queue.task_done()
|
||||
break
|
||||
|
||||
filepath, img_url = task
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'")
|
||||
# We log skips to show it's checking files
|
||||
progress_callback(f" -> Skip (Exists): '{filename}'")
|
||||
download_stats['skipped'] += 1
|
||||
else:
|
||||
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...")
|
||||
task_queue.task_done()
|
||||
continue
|
||||
|
||||
# --- RETRY LOGIC START ---
|
||||
success = False
|
||||
# UNCOMMENTED: Log the start of download so you see activity
|
||||
progress_callback(f" Downloading: '{filename}'...")
|
||||
|
||||
for attempt in range(10): # Try 10 times
|
||||
try:
|
||||
if attempt > 0:
|
||||
progress_callback(f" ⚠️ Retrying '{filename}' (Attempt {attempt+1}/10)...")
|
||||
time.sleep(2)
|
||||
|
||||
response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
|
||||
response.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
|
||||
success = True
|
||||
# UNCOMMENTED: Log success
|
||||
progress_callback(f" ✅ Downloaded: '{filename}'")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
progress_callback(f" ❌ Download failed for task. Error: {e}")
|
||||
download_stats['skipped'] += 1
|
||||
finally:
|
||||
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')
|
||||
@ -178,27 +226,33 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
executor.submit(downloader_worker)
|
||||
|
||||
page_number = 1
|
||||
progress_callback(" [Hentai2Read] Scanning pages...") # Initial log
|
||||
|
||||
while True:
|
||||
if check_pause_func(): break
|
||||
if page_number > 300: # Safety break
|
||||
if page_number > 300:
|
||||
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
|
||||
break
|
||||
|
||||
# Log occasionally to show scanning is alive
|
||||
if page_number % 10 == 0:
|
||||
progress_callback(f" [Hentai2Read] Scanned {page_number} pages so far...")
|
||||
|
||||
page_url_to_check = f"{chapter_url}{page_number}/"
|
||||
try:
|
||||
page_response = None
|
||||
page_last_exception = None
|
||||
for page_attempt in range(3): # 3 attempts for sub-pages
|
||||
for page_attempt in range(3):
|
||||
try:
|
||||
page_response = scraper.get(page_url_to_check, timeout=30)
|
||||
page_last_exception = None
|
||||
break
|
||||
except Exception as e:
|
||||
page_last_exception = e
|
||||
time.sleep(1) # Short delay for page scraping retries
|
||||
time.sleep(1)
|
||||
|
||||
if page_last_exception:
|
||||
raise page_last_exception # Give up after 3 tries
|
||||
raise page_last_exception
|
||||
|
||||
if page_response.history or page_response.status_code != 200:
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
|
||||
@ -209,7 +263,7 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
img_src = img_tag.get("src") if img_tag else None
|
||||
|
||||
if not img_tag or img_src == "https://static.hentai.direct/hentai":
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected (Last page reached at {page_number}).")
|
||||
break
|
||||
|
||||
normalized_img_src = urljoin(page_response.url, img_src)
|
||||
@ -220,15 +274,19 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
task_queue.put((filepath, normalized_img_src))
|
||||
|
||||
page_number += 1
|
||||
time.sleep(0.1) # Small delay between scraping pages
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
|
||||
break
|
||||
|
||||
# Signal workers to exit
|
||||
for _ in range(num_download_threads):
|
||||
task_queue.put(None)
|
||||
|
||||
# Wait for all tasks to complete
|
||||
task_queue.join()
|
||||
executor.shutdown(wait=True)
|
||||
|
||||
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
|
||||
return download_stats['downloaded'], download_stats['skipped']
|
||||
progress_callback(f" Chapter complete. Processed {page_number - 1} images.")
|
||||
|
||||
return download_stats['downloaded'], download_stats['skipped'], failed_files_list
|
||||
@ -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,7 +39,7 @@ 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)
|
||||
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()
|
||||
@ -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,7 +120,8 @@ 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)
|
||||
# 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()
|
||||
@ -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)
|
||||
# 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).")
|
||||
174
src/core/deviantart_client.py
Normal file
174
src/core/deviantart_client.py
Normal file
@ -0,0 +1,174 @@
|
||||
import requests
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
|
||||
class DeviantArtClient:
|
||||
# Public Client Credentials
|
||||
CLIENT_ID = "5388"
|
||||
CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1"
|
||||
BASE_API = "https://www.deviantart.com/api/v1/oauth2"
|
||||
|
||||
def __init__(self, logger_func=print):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
})
|
||||
self.access_token = None
|
||||
self.logger = logger_func
|
||||
|
||||
# --- DEDUPLICATION LOGIC ---
|
||||
self.logged_waits = set()
|
||||
self.log_lock = threading.Lock()
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticates using client credentials flow."""
|
||||
try:
|
||||
url = "https://www.deviantart.com/oauth2/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.CLIENT_ID,
|
||||
"client_secret": self.CLIENT_SECRET
|
||||
}
|
||||
resp = self.session.post(url, data=data, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self.access_token = data.get("access_token")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger(f"DA Auth Error: {e}")
|
||||
return False
|
||||
|
||||
def _api_call(self, endpoint, params=None):
|
||||
if not self.access_token:
|
||||
if not self.authenticate():
|
||||
raise Exception("Authentication failed")
|
||||
|
||||
url = f"{self.BASE_API}{endpoint}"
|
||||
params = params or {}
|
||||
params['access_token'] = self.access_token
|
||||
params['mature_content'] = 'true'
|
||||
|
||||
retries = 0
|
||||
max_retries = 4
|
||||
backoff_delay = 2
|
||||
|
||||
while True:
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=20)
|
||||
|
||||
# Handle Token Expiration (401)
|
||||
if resp.status_code == 401:
|
||||
self.logger(" [DeviantArt] Token expired. Refreshing...")
|
||||
if self.authenticate():
|
||||
params['access_token'] = self.access_token
|
||||
continue
|
||||
else:
|
||||
raise Exception("Failed to refresh token")
|
||||
|
||||
# Handle Rate Limiting (429)
|
||||
if resp.status_code == 429:
|
||||
if retries < max_retries:
|
||||
retry_after = resp.headers.get('Retry-After')
|
||||
|
||||
if retry_after:
|
||||
sleep_time = int(retry_after) + 1
|
||||
msg = f" [DeviantArt] ⚠️ Rate limit (Server says wait {sleep_time}s)."
|
||||
else:
|
||||
sleep_time = backoff_delay * (2 ** retries)
|
||||
msg = f" [DeviantArt] ⚠️ Rate limit reached. Retrying in {sleep_time}s..."
|
||||
|
||||
# --- THREAD-SAFE LOGGING CHECK ---
|
||||
should_log = False
|
||||
with self.log_lock:
|
||||
if sleep_time not in self.logged_waits:
|
||||
self.logged_waits.add(sleep_time)
|
||||
should_log = True
|
||||
|
||||
if should_log:
|
||||
self.logger(msg)
|
||||
|
||||
time.sleep(sleep_time)
|
||||
retries += 1
|
||||
continue
|
||||
else:
|
||||
resp.raise_for_status()
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
# Clear log history on success so we get warned again if limits return later
|
||||
with self.log_lock:
|
||||
if self.logged_waits:
|
||||
self.logged_waits.clear()
|
||||
|
||||
return resp.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if retries < max_retries:
|
||||
# Using the lock here too to prevent connection error spam
|
||||
should_log = False
|
||||
with self.log_lock:
|
||||
if "conn_error" not in self.logged_waits:
|
||||
self.logged_waits.add("conn_error")
|
||||
should_log = True
|
||||
|
||||
if should_log:
|
||||
self.logger(f" [DeviantArt] Connection error: {e}. Retrying...")
|
||||
|
||||
time.sleep(2)
|
||||
retries += 1
|
||||
continue
|
||||
raise e
|
||||
|
||||
def get_deviation_uuid(self, url):
|
||||
"""Scrapes the deviation page to find the UUID."""
|
||||
try:
|
||||
resp = self.session.get(url, timeout=15)
|
||||
match = re.search(r'"deviationUuid":"([^"]+)"', resp.text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
match = re.search(r'-(\d+)$', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception as e:
|
||||
self.logger(f"Error scraping UUID: {e}")
|
||||
return None
|
||||
|
||||
def get_deviation_content(self, uuid):
|
||||
"""Fetches download info."""
|
||||
try:
|
||||
data = self._api_call(f"/deviation/download/{uuid}")
|
||||
if 'src' in data:
|
||||
return data
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
meta = self._api_call(f"/deviation/{uuid}")
|
||||
if 'content' in meta:
|
||||
return meta['content']
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_gallery_folder(self, username, offset=0, limit=24):
|
||||
"""Fetches items from a user's gallery."""
|
||||
return self._api_call("/gallery/all", {"username": username, "offset": offset, "limit": limit})
|
||||
|
||||
@staticmethod
|
||||
def extract_info_from_url(url):
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip('/')
|
||||
parts = path.split('/')
|
||||
|
||||
if len(parts) >= 3 and parts[1] == 'art':
|
||||
return 'post', parts[0], parts[2]
|
||||
elif len(parts) >= 2 and parts[1] == 'gallery':
|
||||
return 'gallery', parts[0], None
|
||||
elif len(parts) == 1:
|
||||
return 'gallery', parts[0], None
|
||||
|
||||
return None, None, None
|
||||
@ -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
|
||||
|
||||
if self.single_pdf_mode:
|
||||
content_data = {
|
||||
# --- 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')
|
||||
'published': self.post.get('published') or self.post.get('added'),
|
||||
'service': service_str,
|
||||
'user': user_id_str,
|
||||
'id': post_id_str,
|
||||
'tags': self.post.get('tags'),
|
||||
'original_link': post_page_url,
|
||||
'creator_name': creator_name
|
||||
}
|
||||
|
||||
# --- Single PDF Mode (Save Temp JSON) ---
|
||||
if self.single_pdf_mode:
|
||||
if self.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 = ""
|
||||
|
||||
if self.manga_mode_active:
|
||||
txt_filename = self._get_manga_style_filename_for_post(post_title, f".{file_extension}")
|
||||
self.logger(f" ℹ️ Applying Renaming Mode. Generated filename: '{txt_filename}'")
|
||||
else:
|
||||
txt_filename = clean_filename(post_title) + f".{file_extension}"
|
||||
|
||||
final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename)
|
||||
|
||||
try:
|
||||
os.makedirs(determined_post_save_path_for_history, exist_ok=True)
|
||||
base, ext = os.path.splitext(final_save_path)
|
||||
|
||||
counter = 1
|
||||
while os.path.exists(final_save_path):
|
||||
final_save_path = f"{base}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
# --- PDF Generation ---
|
||||
if file_extension == 'pdf':
|
||||
if FPDF:
|
||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
||||
pdf = PDF()
|
||||
base_path = self.project_root_dir
|
||||
# Font setup
|
||||
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.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':
|
||||
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)
|
||||
common_content_data['comments_list_for_pdf'] = comments_data
|
||||
else:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(0, 7, cleaned_text)
|
||||
common_content_data['content_text_for_pdf'] = cleaned_text
|
||||
|
||||
pdf.output(final_save_path)
|
||||
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)
|
||||
# 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)")
|
||||
|
||||
208
src/ui/classes/deviantart_downloader_thread.py
Normal file
208
src/ui/classes/deviantart_downloader_thread.py
Normal file
@ -0,0 +1,208 @@
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
import re
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
from ...core.deviantart_client import DeviantArtClient
|
||||
from ...utils.file_utils import clean_folder_name
|
||||
|
||||
class DeviantArtDownloadThread(QThread):
|
||||
progress_signal = pyqtSignal(str)
|
||||
file_progress_signal = pyqtSignal(str, object)
|
||||
overall_progress_signal = pyqtSignal(int, int)
|
||||
finished_signal = pyqtSignal(int, int, bool, list)
|
||||
|
||||
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None):
|
||||
super().__init__(parent)
|
||||
self.url = url
|
||||
self.output_dir = output_dir
|
||||
self.pause_event = pause_event
|
||||
self.cancellation_event = cancellation_event
|
||||
|
||||
# --- PASS LOGGER TO CLIENT ---
|
||||
# This ensures client logs go to the UI, not just the black console window
|
||||
self.client = DeviantArtClient(logger_func=self.progress_signal.emit)
|
||||
|
||||
self.parent_app = parent
|
||||
self.download_count = 0
|
||||
self.skip_count = 0
|
||||
|
||||
# --- THREAD SETTINGS ---
|
||||
self.max_threads = 10
|
||||
|
||||
def run(self):
|
||||
self.progress_signal.emit("=" * 40)
|
||||
self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}")
|
||||
self.progress_signal.emit(f" ℹ️ Using {self.max_threads} parallel threads.")
|
||||
|
||||
try:
|
||||
if not self.client.authenticate():
|
||||
self.progress_signal.emit("❌ Failed to authenticate with DeviantArt API.")
|
||||
self.finished_signal.emit(0, 0, True, [])
|
||||
return
|
||||
|
||||
mode, username, _ = self.client.extract_info_from_url(self.url)
|
||||
|
||||
if mode == 'post':
|
||||
self._process_single_post(self.url)
|
||||
elif mode == 'gallery':
|
||||
self._process_gallery(username)
|
||||
else:
|
||||
self.progress_signal.emit("❌ Could not parse DeviantArt URL type.")
|
||||
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f"❌ Error during download: {e}")
|
||||
self.skip_count += 1
|
||||
finally:
|
||||
self.finished_signal.emit(self.download_count, self.skip_count, self.cancellation_event.is_set(), [])
|
||||
|
||||
def _check_pause_cancel(self):
|
||||
if self.cancellation_event.is_set(): return True
|
||||
while self.pause_event.is_set():
|
||||
time.sleep(0.5)
|
||||
if self.cancellation_event.is_set(): return True
|
||||
return False
|
||||
|
||||
def _process_single_post(self, url):
|
||||
self.progress_signal.emit(f" Fetching deviation info...")
|
||||
uuid = self.client.get_deviation_uuid(url)
|
||||
if not uuid:
|
||||
self.progress_signal.emit("❌ Could not find Deviation UUID.")
|
||||
self.skip_count += 1
|
||||
return
|
||||
|
||||
meta = self.client._api_call(f"/deviation/{uuid}")
|
||||
content = self.client.get_deviation_content(uuid)
|
||||
if not content:
|
||||
self.progress_signal.emit("❌ Could not retrieve download URL.")
|
||||
self.skip_count += 1
|
||||
return
|
||||
|
||||
self._download_file(content['src'], meta)
|
||||
|
||||
def _process_gallery(self, username):
|
||||
self.progress_signal.emit(f" Fetching gallery for user: {username}...")
|
||||
offset = 0
|
||||
has_more = True
|
||||
|
||||
base_folder = os.path.join(self.output_dir, clean_folder_name(username))
|
||||
if not os.path.exists(base_folder):
|
||||
os.makedirs(base_folder, exist_ok=True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_threads) as executor:
|
||||
while has_more:
|
||||
if self._check_pause_cancel(): break
|
||||
|
||||
data = self.client.get_gallery_folder(username, offset=offset)
|
||||
results = data.get('results', [])
|
||||
has_more = data.get('has_more', False)
|
||||
offset = data.get('next_offset')
|
||||
|
||||
if not results: break
|
||||
|
||||
futures = []
|
||||
for deviation in results:
|
||||
if self._check_pause_cancel(): break
|
||||
future = executor.submit(self._process_deviation_task, deviation, base_folder)
|
||||
futures.append(future)
|
||||
|
||||
wait(futures)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
def _process_deviation_task(self, deviation, base_folder):
|
||||
if self._check_pause_cancel(): return
|
||||
|
||||
dev_id = deviation.get('deviationid')
|
||||
title = deviation.get('title', 'Unknown')
|
||||
|
||||
try:
|
||||
content = self.client.get_deviation_content(dev_id)
|
||||
if content:
|
||||
self._download_file(content['src'], deviation, override_dir=base_folder)
|
||||
else:
|
||||
self.skip_count += 1
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" ❌ Error processing {title}: {e}")
|
||||
self.skip_count += 1
|
||||
|
||||
def _format_date(self, timestamp):
|
||||
if not timestamp: return "NoDate"
|
||||
try:
|
||||
fmt_setting = self.parent_app.manga_custom_date_format
|
||||
strftime_fmt = fmt_setting.replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
|
||||
dt_obj = datetime.fromtimestamp(int(timestamp))
|
||||
return dt_obj.strftime(strftime_fmt)
|
||||
except Exception:
|
||||
return "InvalidDate"
|
||||
|
||||
def _download_file(self, file_url, metadata, override_dir=None):
|
||||
if self._check_pause_cancel(): return
|
||||
|
||||
parsed = requests.utils.urlparse(file_url)
|
||||
path_filename = os.path.basename(parsed.path)
|
||||
if '?' in path_filename: path_filename = path_filename.split('?')[0]
|
||||
_, ext = os.path.splitext(path_filename)
|
||||
|
||||
title = metadata.get('title', 'Untitled')
|
||||
safe_title = clean_folder_name(title)
|
||||
if not safe_title: safe_title = "Untitled"
|
||||
|
||||
final_filename = f"{safe_title}{ext}"
|
||||
|
||||
if self.parent_app and self.parent_app.manga_mode_checkbox.isChecked():
|
||||
try:
|
||||
creator_name = metadata.get('author', {}).get('username', 'Unknown')
|
||||
published_ts = metadata.get('published_time')
|
||||
|
||||
fmt_data = {
|
||||
"creator_name": creator_name,
|
||||
"title": title,
|
||||
"published": self._format_date(published_ts),
|
||||
"added": self._format_date(published_ts),
|
||||
"edited": self._format_date(published_ts),
|
||||
"id": metadata.get('deviationid', ''),
|
||||
"service": "deviantart",
|
||||
"name": safe_title
|
||||
}
|
||||
|
||||
custom_fmt = self.parent_app.custom_manga_filename_format
|
||||
new_name = custom_fmt.format(**fmt_data)
|
||||
final_filename = f"{clean_folder_name(new_name)}{ext}"
|
||||
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" ⚠️ Renaming failed ({e}), using default.")
|
||||
|
||||
save_dir = override_dir if override_dir else self.output_dir
|
||||
if not os.path.exists(save_dir):
|
||||
try:
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
except OSError: pass
|
||||
|
||||
filepath = os.path.join(save_dir, final_filename)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
return
|
||||
|
||||
try:
|
||||
self.progress_signal.emit(f" ⬇️ Downloading: {final_filename}")
|
||||
|
||||
with requests.get(file_url, stream=True, timeout=30) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if self._check_pause_cancel():
|
||||
f.close()
|
||||
os.remove(filepath)
|
||||
return
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
self.download_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" ❌ Download failed: {e}")
|
||||
self.skip_count += 1
|
||||
@ -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
|
||||
|
||||
@ -254,6 +254,7 @@ class SimpCityDownloadThread(QThread):
|
||||
self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
|
||||
self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
|
||||
self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
|
||||
self.should_dl_images = self.parent_app.simpcity_dl_images_cb.isChecked()
|
||||
self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
|
||||
self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
|
||||
|
||||
@ -288,8 +289,10 @@ class SimpCityDownloadThread(QThread):
|
||||
enriched_jobs = self._get_enriched_jobs(jobs)
|
||||
if enriched_jobs:
|
||||
for job in enriched_jobs:
|
||||
if job['type'] == 'image': self.image_queue.put(job)
|
||||
if job['type'] == 'image':
|
||||
if self.should_dl_images: self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
|
||||
else:
|
||||
base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
|
||||
page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
|
||||
@ -347,11 +350,14 @@ class SimpCityDownloadThread(QThread):
|
||||
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
|
||||
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
else:
|
||||
for job in enriched_jobs:
|
||||
self.processed_job_urls.add(job.get('url'))
|
||||
if job['type'] == 'image': self.image_queue.put(job)
|
||||
if job['type'] == 'image':
|
||||
if self.should_dl_images: self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
|
||||
page_fetch_successful = True; break
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code in [403, 404]:
|
||||
|
||||
@ -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)
|
||||
|
||||
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()
|
||||
@ -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 ---
|
||||
|
||||
@ -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("")
|
||||
@ -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,32 +199,19 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
default_font_family = 'DejaVu'
|
||||
|
||||
bold_font_path = ""
|
||||
if font_path:
|
||||
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
font_family = _setup_pdf_fonts(pdf, font_path, logger)
|
||||
|
||||
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for i, post in enumerate(posts_data):
|
||||
if i > 0:
|
||||
# This ensures every post after the first gets its own page.
|
||||
if add_info_page:
|
||||
add_metadata_page(pdf, post, font_family)
|
||||
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
|
||||
else:
|
||||
pdf.add_page()
|
||||
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -7,7 +7,7 @@ import sys
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@ -27,6 +27,11 @@ class UpdateCheckDialog(QDialog):
|
||||
self.user_data_path = user_data_path
|
||||
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
|
||||
|
||||
self._default_checkbox_tooltip = (
|
||||
"If checked, the settings from the selected profile will be loaded into the main window.\n"
|
||||
"You can then modify them. When you start the download, the new settings will be saved to the profile."
|
||||
)
|
||||
|
||||
self._init_ui()
|
||||
self._load_profiles()
|
||||
self._retranslate_ui()
|
||||
@ -56,8 +61,16 @@ class UpdateCheckDialog(QDialog):
|
||||
self.list_widget = QListWidget()
|
||||
# No selection mode, we only care about checkboxes
|
||||
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
||||
# Connect signal to handle checkbox state changes
|
||||
self.list_widget.itemChanged.connect(self._handle_item_changed)
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
# --- NEW: Checkbox to Load Settings ---
|
||||
self.load_settings_checkbox = QCheckBox("Load profile settings into UI (Edit Settings)")
|
||||
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
|
||||
layout.addWidget(self.load_settings_checkbox)
|
||||
# -------------------------------------
|
||||
|
||||
# --- All Buttons in One Horizontal Layout ---
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6) # small even spacing between all buttons
|
||||
@ -97,6 +110,7 @@ class UpdateCheckDialog(QDialog):
|
||||
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
|
||||
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
|
||||
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
|
||||
self.load_settings_checkbox.setText(self._tr("update_check_load_settings_checkbox", "Load profile settings into UI (Edit Settings)"))
|
||||
|
||||
def _load_profiles(self):
|
||||
"""Loads all .json files from the creator_profiles directory as checkable items."""
|
||||
@ -144,16 +158,44 @@ class UpdateCheckDialog(QDialog):
|
||||
self.check_button.setEnabled(False)
|
||||
self.select_all_button.setEnabled(False)
|
||||
self.deselect_all_button.setEnabled(False)
|
||||
self.load_settings_checkbox.setEnabled(False)
|
||||
|
||||
def _toggle_all_checkboxes(self):
|
||||
"""Handles Select All and Deselect All button clicks."""
|
||||
sender = self.sender()
|
||||
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
|
||||
|
||||
# Block signals to prevent triggering _handle_item_changed repeatedly
|
||||
self.list_widget.blockSignals(True)
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setCheckState(check_state)
|
||||
self.list_widget.blockSignals(False)
|
||||
|
||||
# Manually trigger the update once after batch change
|
||||
self._handle_item_changed(None)
|
||||
|
||||
def _handle_item_changed(self, item):
|
||||
"""
|
||||
Monitors how many items are checked.
|
||||
If more than 1 item is checked, disable the 'Load Settings' checkbox.
|
||||
"""
|
||||
checked_count = 0
|
||||
for i in range(self.list_widget.count()):
|
||||
if self.list_widget.item(i).checkState() == Qt.Checked:
|
||||
checked_count += 1
|
||||
|
||||
if checked_count > 1:
|
||||
self.load_settings_checkbox.setChecked(False)
|
||||
self.load_settings_checkbox.setEnabled(False)
|
||||
self.load_settings_checkbox.setToolTip(
|
||||
self._tr("update_check_multi_selection_warning",
|
||||
"Editing settings is disabled when multiple profiles are selected.")
|
||||
)
|
||||
else:
|
||||
self.load_settings_checkbox.setEnabled(True)
|
||||
self.load_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
|
||||
|
||||
def on_check_selected(self):
|
||||
"""Handles the 'Check Selected' button click."""
|
||||
@ -177,3 +219,8 @@ class UpdateCheckDialog(QDialog):
|
||||
def get_selected_profiles(self):
|
||||
"""Returns the list of profile data selected by the user."""
|
||||
return self.selected_profiles_list
|
||||
|
||||
def should_load_into_ui(self):
|
||||
"""Returns True if the 'Load settings into UI' checkbox is checked."""
|
||||
# Only return True if it's enabled and checked (double safety)
|
||||
return self.load_settings_checkbox.isEnabled() and self.load_settings_checkbox.isChecked()
|
||||
|
||||
@ -163,6 +163,7 @@ class DownloaderApp (QWidget ):
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.is_finishing = False
|
||||
self.finish_lock = threading.Lock()
|
||||
self.add_info_in_pdf_setting = False
|
||||
|
||||
saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
|
||||
if saved_res != "Auto":
|
||||
@ -339,7 +340,7 @@ class DownloaderApp (QWidget ):
|
||||
self.download_location_label_widget = None
|
||||
self.remove_from_filename_label_widget = None
|
||||
self.skip_words_label_widget = None
|
||||
self.setWindowTitle("Kemono Downloader v6.7.0")
|
||||
self.setWindowTitle("Kemono Downloader v7.8.0")
|
||||
setup_ui(self)
|
||||
self._connect_signals()
|
||||
if hasattr(self, 'character_input'):
|
||||
@ -656,6 +657,7 @@ class DownloaderApp (QWidget ):
|
||||
settings['more_filter_scope'] = self.more_filter_scope
|
||||
settings['text_export_format'] = self.text_export_format
|
||||
settings['single_pdf_setting'] = self.single_pdf_setting
|
||||
settings['add_info_in_pdf'] = self.add_info_in_pdf_setting # Save to settings dict
|
||||
settings['keep_duplicates_mode'] = self.keep_duplicates_mode
|
||||
settings['keep_duplicates_limit'] = self.keep_duplicates_limit
|
||||
|
||||
@ -936,7 +938,7 @@ class DownloaderApp (QWidget ):
|
||||
domain_override = download_commands.get('domain_override')
|
||||
|
||||
args_template = self.last_start_download_args
|
||||
|
||||
args_template['add_info_in_pdf'] = self.add_info_in_pdf_setting
|
||||
args_template['filter_character_list'] = parsed_filters
|
||||
args_template['domain_override'] = domain_override
|
||||
|
||||
@ -2938,33 +2940,59 @@ class DownloaderApp (QWidget ):
|
||||
return True
|
||||
|
||||
def _handle_more_options_toggled(self, button, checked):
|
||||
"""Shows the MoreOptionsDialog when the 'More' radio button is selected."""
|
||||
"""
|
||||
Handles the toggle event for the 'More' radio button.
|
||||
Opens the configuration dialog when checked and resets state when unchecked.
|
||||
"""
|
||||
# Case 1: The "More" button was just selected
|
||||
if button == self.radio_more and checked:
|
||||
# Determine initial values for the dialog based on current state or defaults
|
||||
current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT
|
||||
current_format = self.text_export_format or 'pdf'
|
||||
|
||||
# Initialize the dialog with the 'add_info_checked' parameter
|
||||
dialog = MoreOptionsDialog(
|
||||
self,
|
||||
current_scope=current_scope,
|
||||
current_format=current_format,
|
||||
single_pdf_checked=self.single_pdf_setting
|
||||
single_pdf_checked=self.single_pdf_setting,
|
||||
add_info_checked=self.add_info_in_pdf_setting # <--- Pass current setting
|
||||
)
|
||||
|
||||
# Show the dialog and wait for user action
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
# --- User clicked OK: Update settings ---
|
||||
self.more_filter_scope = dialog.get_selected_scope()
|
||||
self.text_export_format = dialog.get_selected_format()
|
||||
self.single_pdf_setting = dialog.get_single_pdf_state()
|
||||
self.add_info_in_pdf_setting = dialog.get_add_info_state() # <--- Update setting
|
||||
|
||||
# --- Update Button Text ---
|
||||
is_any_pdf_mode = (self.text_export_format == 'pdf')
|
||||
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
|
||||
format_display = f" ({self.text_export_format.upper()})"
|
||||
|
||||
# Construct a descriptive label (e.g., "Description (PDF [Single+Info])")
|
||||
format_extras = []
|
||||
if self.single_pdf_setting:
|
||||
format_display = " (Single PDF)"
|
||||
format_extras.append("Single")
|
||||
if is_any_pdf_mode and self.add_info_in_pdf_setting:
|
||||
format_extras.append("Info")
|
||||
|
||||
extra_str = f" [{'+'.join(format_extras)}]" if format_extras else ""
|
||||
format_display = f" ({self.text_export_format.upper()}{extra_str})"
|
||||
|
||||
self.radio_more.setText(f"{scope_text}{format_display}")
|
||||
|
||||
# --- Handle Option Conflicts (Multithreading) ---
|
||||
# PDF generation requires the main thread or careful management, so we disable multithreading
|
||||
if hasattr(self, 'use_multithreading_checkbox'):
|
||||
self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode)
|
||||
if is_any_pdf_mode:
|
||||
self.use_multithreading_checkbox.setChecked(False)
|
||||
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
|
||||
|
||||
# --- Handle Option Conflicts (Subfolders) ---
|
||||
# Single PDF mode consolidates files, so subfolders are often disabled
|
||||
if hasattr(self, 'use_subfolders_checkbox'):
|
||||
self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting)
|
||||
if self.single_pdf_setting:
|
||||
@ -2975,23 +3003,39 @@ class DownloaderApp (QWidget ):
|
||||
if self.single_pdf_setting:
|
||||
self.use_subfolder_per_post_checkbox.setChecked(False)
|
||||
|
||||
self.log_signal.emit(f"ℹ️ 'More' filter scope set to: {scope_text}, Format: {self.text_export_format.upper()}")
|
||||
self.log_signal.emit(f"ℹ️ Single PDF setting: {'Enabled' if self.single_pdf_setting else 'Disabled'}")
|
||||
# --- Logging ---
|
||||
self.log_signal.emit(f"ℹ️ 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
|
||||
if is_any_pdf_mode:
|
||||
self.log_signal.emit("ℹ️ Multithreading automatically disabled for PDF export.")
|
||||
status_single = "Enabled" if self.single_pdf_setting else "Disabled"
|
||||
status_info = "Enabled" if self.add_info_in_pdf_setting else "Disabled"
|
||||
self.log_signal.emit(f" ↳ PDF Options: Single PDF={status_single}, Add Info Page={status_info}")
|
||||
self.log_signal.emit(" ↳ Multithreading disabled for PDF export.")
|
||||
|
||||
else:
|
||||
# --- User clicked Cancel: Revert to default ---
|
||||
self.log_signal.emit("ℹ️ 'More' filter selection cancelled. Reverting to 'All'.")
|
||||
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 ()
|
||||
@ -3136,14 +3180,27 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
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
|
||||
# ... (Cycle logic for single posts) ...
|
||||
if current_style == STYLE_POST_TITLE:
|
||||
new_style = STYLE_DATE_POST_TITLE
|
||||
elif current_style == STYLE_DATE_POST_TITLE:
|
||||
@ -3154,10 +3211,10 @@ class DownloaderApp (QWidget ):
|
||||
new_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
|
||||
# ... (Cycle logic for creators) ...
|
||||
if current_style == STYLE_POST_TITLE:
|
||||
new_style = STYLE_ORIGINAL_NAME
|
||||
elif current_style == STYLE_ORIGINAL_NAME:
|
||||
@ -3169,11 +3226,10 @@ class DownloaderApp (QWidget ):
|
||||
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
|
||||
new_style = STYLE_CUSTOM
|
||||
elif current_style == STYLE_CUSTOM:
|
||||
new_style = STYLE_POST_TITLE
|
||||
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
|
||||
@ -3181,7 +3237,6 @@ class DownloaderApp (QWidget ):
|
||||
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 }'")
|
||||
|
||||
def _handle_favorite_mode_toggle (self ,checked ):
|
||||
if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
|
||||
@ -3238,7 +3293,6 @@ class DownloaderApp (QWidget ):
|
||||
is_discord_url = (service == 'discord')
|
||||
|
||||
if is_discord_url:
|
||||
# When a discord URL is detected, disable incompatible options
|
||||
if self.manga_mode_checkbox:
|
||||
self.manga_mode_checkbox.setEnabled(False)
|
||||
self.manga_mode_checkbox.setChecked(False)
|
||||
@ -3247,13 +3301,11 @@ class DownloaderApp (QWidget ):
|
||||
if self.to_label: self.to_label.setEnabled(False)
|
||||
if self.end_page_input: self.end_page_input.setEnabled(False)
|
||||
checked = False # Force manga mode off
|
||||
# --- END: NEW DISCORD UI LOGIC ---
|
||||
|
||||
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
|
||||
is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked ()
|
||||
is_only_audio_mode =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
|
||||
|
||||
# The rest of the original function continues from here...
|
||||
_ ,_ ,post_id =extract_post_info (url_text )
|
||||
|
||||
is_creator_feed =not post_id if url_text else False
|
||||
@ -3280,7 +3332,6 @@ class DownloaderApp (QWidget ):
|
||||
if self .manga_rename_toggle_button :
|
||||
self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
|
||||
|
||||
# --- MODIFIED: Added check for is_discord_url ---
|
||||
if not is_discord_url:
|
||||
self .update_page_range_enabled_state ()
|
||||
|
||||
@ -3318,7 +3369,20 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def _show_custom_rename_dialog(self):
|
||||
"""Shows the dialog to edit the custom manga filename format."""
|
||||
dialog = CustomFilenameDialog(self.custom_manga_filename_format, self.manga_custom_date_format, self)
|
||||
|
||||
# 1. Detect if the current URL is DeviantArt
|
||||
url_text = self.link_input.text().strip() if self.link_input else ""
|
||||
service, _, _ = extract_post_info(url_text)
|
||||
is_deviantart = (service == 'deviantart')
|
||||
|
||||
# 2. Pass the 'is_deviantart' flag to the dialog
|
||||
dialog = CustomFilenameDialog(
|
||||
self.custom_manga_filename_format,
|
||||
self.manga_custom_date_format,
|
||||
self,
|
||||
is_deviantart=is_deviantart # <--- THIS WAS MISSING
|
||||
)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.custom_manga_filename_format = dialog.get_format_string()
|
||||
self.manga_custom_date_format = dialog.get_date_format_string()
|
||||
@ -3328,7 +3392,6 @@ class DownloaderApp (QWidget ):
|
||||
self.log_signal.emit(f"ℹ️ Custom date format set to: '{self.manga_custom_date_format}'")
|
||||
self._update_manga_filename_style_button_text()
|
||||
|
||||
|
||||
def update_multithreading_label (self ,text ):
|
||||
if self .use_multithreading_checkbox .isChecked ():
|
||||
base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading")
|
||||
@ -3468,13 +3531,53 @@ class DownloaderApp (QWidget ):
|
||||
url_text = self.link_input.text().strip()
|
||||
service, _, _ = extract_post_info(url_text)
|
||||
|
||||
# --- DEFINE VARIABLES FIRST ---
|
||||
is_deviantart = (service == 'deviantart')
|
||||
is_simpcity = (service == 'simpcity')
|
||||
is_any_discord_url = (service == 'discord')
|
||||
is_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text
|
||||
is_erome = 'erome.com' in url_text
|
||||
is_fap_nation = 'fap-nation.com' in url_text or 'fap-nation.org' in url_text
|
||||
|
||||
# --- UPDATE SPECIALIZED LIST ---
|
||||
is_specialized = service in ['bunkr', 'nhentai', 'hentai2read', 'simpcity', 'deviantart'] or is_saint2 or is_erome
|
||||
|
||||
# We disable standard UI elements for these services to prevent conflicts
|
||||
is_specialized_for_disabling = service in ['bunkr', 'nhentai', 'hentai2read', 'deviantart'] or is_saint2 or is_erome
|
||||
|
||||
self._set_ui_for_specialized_downloader(is_specialized_for_disabling)
|
||||
|
||||
if hasattr(self, 'advanced_settings_widget'):
|
||||
self.advanced_settings_widget.setVisible(not is_simpcity)
|
||||
if hasattr(self, 'simpcity_settings_widget'):
|
||||
self.simpcity_settings_widget.setVisible(is_simpcity)
|
||||
|
||||
is_any_discord_url = (service == 'discord')
|
||||
if is_deviantart:
|
||||
from ..config.constants import STYLE_CUSTOM
|
||||
|
||||
if self.manga_filename_style != STYLE_CUSTOM:
|
||||
self.log_signal.emit("ℹ️ DeviantArt mode allows only Custom Renaming format. Switched to Custom.")
|
||||
if self.manga_mode_checkbox:
|
||||
self.manga_mode_checkbox.setEnabled(True)
|
||||
self.manga_mode_checkbox.setToolTip("Enable Custom Renaming for DeviantArt")
|
||||
|
||||
self.manga_filename_style = STYLE_CUSTOM
|
||||
|
||||
self._update_manga_filename_style_button_text()
|
||||
|
||||
if self.manga_rename_toggle_button:
|
||||
self.manga_rename_toggle_button.setEnabled(False)
|
||||
self.manga_rename_toggle_button.setToolTip("DeviantArt only supports Custom Renaming.")
|
||||
|
||||
if self.manga_mode_checkbox and self.manga_mode_checkbox.isChecked():
|
||||
if self.manga_rename_toggle_button:
|
||||
self.manga_rename_toggle_button.setVisible(True)
|
||||
if hasattr(self, 'custom_rename_dialog_button'):
|
||||
self.custom_rename_dialog_button.setVisible(True)
|
||||
else:
|
||||
if self.manga_rename_toggle_button:
|
||||
self.manga_rename_toggle_button.setEnabled(True)
|
||||
|
||||
is_official_discord_url = 'discord.com' in url_text and is_any_discord_url
|
||||
|
||||
if is_official_discord_url:
|
||||
@ -3489,15 +3592,6 @@ class DownloaderApp (QWidget ):
|
||||
self.remove_from_filename_input.setPlaceholderText(self._tr("remove_from_filename_input_placeholder_text", "e.g., patreon, HD"))
|
||||
self.remove_from_filename_input.setEchoMode(QLineEdit.Normal)
|
||||
|
||||
is_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text
|
||||
is_erome = 'erome.com' in url_text
|
||||
is_fap_nation = 'fap-nation.com' in url_text or 'fap-nation.org' in url_text
|
||||
|
||||
is_specialized = service in ['bunkr', 'nhentai', 'hentai2read', 'simpcity'] or is_saint2 or is_erome
|
||||
|
||||
is_specialized_for_disabling = service in ['bunkr', 'nhentai', 'hentai2read'] or is_saint2 or is_erome
|
||||
self._set_ui_for_specialized_downloader(is_specialized_for_disabling)
|
||||
|
||||
self.discord_scope_toggle_button.setVisible(is_any_discord_url)
|
||||
if hasattr(self, 'discord_message_limit_input'):
|
||||
self.discord_message_limit_input.setVisible(is_official_discord_url)
|
||||
@ -3534,6 +3628,11 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
|
||||
|
||||
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, MAX_FILE_THREADS_PER_POST_OR_WORKER
|
||||
|
||||
from ..utils.file_utils import clean_folder_name, KNOWN_NAMES
|
||||
from ..config.constants import FOLDER_NAME_STOP_WORDS
|
||||
|
||||
if not is_restore and not is_continuation:
|
||||
if self.main_log_output: self.main_log_output.clear()
|
||||
if self.external_log_output: self.external_log_output.clear()
|
||||
@ -3548,6 +3647,28 @@ class DownloaderApp (QWidget ):
|
||||
if not direct_api_url:
|
||||
api_url_text = self.link_input.text().strip().lower()
|
||||
batch_handlers = {
|
||||
'kemono.cr': {
|
||||
'name': 'Kemono Batch',
|
||||
'txt_file': 'kemono.txt',
|
||||
'url_regex': r'https?://(?:www\.)?kemono\.(?:su|party|cr)/[^/\s]+/user/\d+(?:/post/\d+)?/?'
|
||||
},
|
||||
'kemono.su': {
|
||||
'name': 'Kemono Batch',
|
||||
'txt_file': 'kemono.txt',
|
||||
'url_regex': r'https?://(?:www\.)?kemono\.(?:su|party|cr)/[^/\s]+/user/\d+(?:/post/\d+)?/?'
|
||||
},
|
||||
|
||||
'coomer.st': {
|
||||
'name': 'Coomer Batch',
|
||||
'txt_file': 'coomer.txt',
|
||||
'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?'
|
||||
},
|
||||
'coomer.su': {
|
||||
'name': 'Coomer Batch',
|
||||
'txt_file': 'coomer.txt',
|
||||
'url_regex': r'https?://(?:www\.)?coomer\.(?:su|party|st)/[^/\s]+/user/[^/\s]+(?:/post/\d+)?/?'
|
||||
},
|
||||
|
||||
'allporncomic.com': {
|
||||
'name': 'AllPornComic',
|
||||
'txt_file': 'allporncomic.txt',
|
||||
@ -3621,21 +3742,75 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
|
||||
self.favorite_download_queue.clear()
|
||||
|
||||
self_managed_folder_sites = [
|
||||
'allporncomic.com', 'allcomic.com',
|
||||
'fap-nation.com', 'fap-nation.org',
|
||||
'toonily.com', 'toonily.me',
|
||||
'hentai2read.com',
|
||||
'saint2.su', 'saint2.pk',
|
||||
'imgur.com', 'bunkr.'
|
||||
]
|
||||
|
||||
for url in urls_to_download:
|
||||
# 1. Check if this site manages its own folders
|
||||
is_self_managed = any(site in url.lower() for site in self_managed_folder_sites)
|
||||
|
||||
# 2. Extract info
|
||||
service_extracted, user_id_extracted, pid = extract_post_info(url)
|
||||
|
||||
# Default values
|
||||
force_folder = False
|
||||
folder_name_to_use = "Unknown_Batch_Item"
|
||||
item_type = 'post' # Default to post/comic
|
||||
|
||||
if is_self_managed:
|
||||
# Case A: Self-Managed Sites (AllPornComic, FapNation, etc.)
|
||||
# We do NOT force a folder here. We let the specialized downloader create
|
||||
# the correct folder (e.g. "Main Title") inside the root directory.
|
||||
force_folder = False
|
||||
folder_name_to_use = "Auto-Detected" # Placeholder, won't be used for root folder
|
||||
item_type = 'post'
|
||||
|
||||
elif service_extracted:
|
||||
# Case B: Standard ID-based sites (Kemono, Coomer, Nhentai, etc.)
|
||||
# We MUST force a folder (Artist Name) to keep them organized.
|
||||
item_type = 'post' if pid else 'artist'
|
||||
force_folder = True
|
||||
|
||||
cache_key = (service_extracted.lower(), str(user_id_extracted))
|
||||
creator_name = self.creator_name_cache.get(cache_key)
|
||||
|
||||
if creator_name:
|
||||
folder_name_to_use = clean_folder_name(creator_name)
|
||||
else:
|
||||
folder_name_to_use = f"{service_extracted}_{user_id_extracted}"
|
||||
|
||||
else:
|
||||
# Case C: Unknown sites (Fallback)
|
||||
# We force a folder based on the URL slug to ensure it doesn't clutter the root.
|
||||
try:
|
||||
path_parts = urlparse(url).path.strip('/').split('/')
|
||||
folder_candidate = path_parts[-1] if path_parts else "Unknown_Item"
|
||||
folder_name_to_use = clean_folder_name(folder_candidate)
|
||||
force_folder = True
|
||||
except Exception:
|
||||
folder_name_to_use = "Unknown_Batch_Item"
|
||||
force_folder = True
|
||||
|
||||
# 3. Add to queue
|
||||
self.favorite_download_queue.append({
|
||||
'url': url,
|
||||
'name': f"{name} link from batch",
|
||||
'type': 'post'
|
||||
'name': f"{name} batch: {folder_name_to_use}",
|
||||
'name_for_folder': folder_name_to_use,
|
||||
'type': item_type,
|
||||
'force_artist_folder': force_folder # <--- Only True for Kemono/Coomer/Unknown
|
||||
})
|
||||
|
||||
if not self.is_processing_favorites_queue:
|
||||
self._process_next_favorite_download()
|
||||
return True # Stop further execution of start_download
|
||||
|
||||
|
||||
from ..utils.file_utils import clean_folder_name
|
||||
from ..config.constants import FOLDER_NAME_STOP_WORDS
|
||||
|
||||
if self.is_ready_to_download_fetched:
|
||||
self._start_download_of_fetched_posts()
|
||||
return True
|
||||
@ -3649,7 +3824,6 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self.is_finishing = False
|
||||
self.downloaded_hash_counts.clear()
|
||||
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, MAX_FILE_THREADS_PER_POST_OR_WORKER
|
||||
|
||||
if not is_restore and not is_continuation:
|
||||
self.permanently_failed_files_for_dialog.clear()
|
||||
@ -4321,7 +4495,8 @@ class DownloaderApp (QWidget ):
|
||||
'start_offset': start_offset_for_restore,
|
||||
'fetch_first': fetch_first_enabled,
|
||||
'sfp_threshold': download_commands.get('sfp_threshold'),
|
||||
'handle_unknown_mode': handle_unknown_command
|
||||
'handle_unknown_mode': handle_unknown_command,
|
||||
'add_info_in_pdf': self.add_info_in_pdf_setting,
|
||||
}
|
||||
|
||||
args_template['override_output_dir'] = override_output_dir
|
||||
@ -4734,6 +4909,7 @@ class DownloaderApp (QWidget ):
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'cookie_text': self.cookie_text_input.text(),
|
||||
'selected_cookie_file': self.selected_cookie_filepath,
|
||||
'add_info_in_pdf': self.add_info_in_pdf_setting,
|
||||
}
|
||||
|
||||
# 2. Define DEFAULTS for all settings that *should* be in the profile.
|
||||
@ -4776,6 +4952,7 @@ class DownloaderApp (QWidget ):
|
||||
'target_post_id_from_initial_url': None,
|
||||
'override_output_dir': None,
|
||||
'processed_post_ids': [],
|
||||
'add_info_in_pdf': False,
|
||||
}
|
||||
|
||||
for item in self.fetched_posts_for_batch_update:
|
||||
@ -4784,18 +4961,33 @@ class DownloaderApp (QWidget ):
|
||||
# --- THIS IS THE NEW, CORRECTED LOGIC ---
|
||||
full_profile_data = item.get('profile_data', {})
|
||||
saved_settings = full_profile_data.get('settings', {})
|
||||
# --- END OF NEW LOGIC ---
|
||||
|
||||
# 3. Construct the final arguments for this specific worker
|
||||
|
||||
# Start with a full set of defaults
|
||||
args_for_this_worker = default_profile_settings.copy()
|
||||
# Overwrite with any settings saved in the profile
|
||||
# This is where {"filter_mode": "video"} from Maplestar.json is applied
|
||||
args_for_this_worker.update(saved_settings)
|
||||
# Add all the live runtime arguments
|
||||
args_for_this_worker.update(live_runtime_args)
|
||||
|
||||
# Check the override flag we set in _show_empty_popup
|
||||
if getattr(self, 'override_update_profile_settings', False):
|
||||
# If overriding, we rely PURELY on live_runtime_args (which reflect current UI state)
|
||||
# We do NOT update with saved_settings.
|
||||
# However, live_runtime_args only has *some* args. We need the FULL UI state.
|
||||
# So we fetch the full UI state now.
|
||||
current_ui_settings = self._get_current_ui_settings_as_dict(
|
||||
api_url_override=saved_settings.get('api_url') # Preserve the URL from profile
|
||||
)
|
||||
args_for_this_worker.update(current_ui_settings)
|
||||
|
||||
# IMPORTANT: Update the profile data in memory so it gets saved correctly later
|
||||
full_profile_data['settings'] = current_ui_settings
|
||||
|
||||
# OPTIONAL: Save the JSON immediately to disk to persist changes even if crash
|
||||
creator_name = item.get('creator_name')
|
||||
if creator_name:
|
||||
self._save_creator_profile(creator_name, full_profile_data, self.session_file_path)
|
||||
|
||||
else:
|
||||
# Standard behavior: Load saved settings, then overlay runtime overrides
|
||||
args_for_this_worker.update(saved_settings)
|
||||
|
||||
# Apply live runtime args (always apply these last as they contain critical objects like emitters)
|
||||
args_for_this_worker.update(live_runtime_args)
|
||||
# 4. Manually parse values from the constructed args
|
||||
|
||||
# Set post-specific data
|
||||
@ -4936,6 +5128,7 @@ class DownloaderApp (QWidget ):
|
||||
self.more_filter_scope = settings.get('more_filter_scope')
|
||||
self.text_export_format = settings.get('text_export_format', 'pdf')
|
||||
self.single_pdf_setting = settings.get('single_pdf_setting', False)
|
||||
self.add_info_in_pdf_setting = settings.get('add_info_in_pdf', False) # Load setting
|
||||
if self.radio_more.isChecked() and self.more_filter_scope:
|
||||
from .dialogs.MoreOptionsDialog import MoreOptionsDialog
|
||||
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
|
||||
@ -5135,7 +5328,13 @@ class DownloaderApp (QWidget ):
|
||||
self.log_signal.emit(" Sorting collected posts by date (oldest first)...")
|
||||
sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z'))
|
||||
|
||||
create_single_pdf_from_content(sorted_content, filepath, font_path, logger=self.log_signal.emit)
|
||||
create_single_pdf_from_content(
|
||||
sorted_content,
|
||||
filepath,
|
||||
font_path,
|
||||
add_info_page=self.add_info_in_pdf_setting, # Pass the flag here
|
||||
logger=self.log_signal.emit
|
||||
)
|
||||
self.log_signal.emit("="*40)
|
||||
|
||||
def _add_to_history_candidates(self, history_data):
|
||||
@ -5533,6 +5732,10 @@ class DownloaderApp (QWidget ):
|
||||
if not self.finish_lock.acquire(blocking=False):
|
||||
return
|
||||
|
||||
# --- Flag to track if we still hold the lock ---
|
||||
lock_held = True
|
||||
# ----------------------------------------------------
|
||||
|
||||
try:
|
||||
if self.is_finishing:
|
||||
return
|
||||
@ -5554,16 +5757,21 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self.log_signal.emit("🏁 Download of current item complete.")
|
||||
|
||||
# --- QUEUE PROCESSING BLOCK ---
|
||||
if self.is_processing_favorites_queue and self.favorite_download_queue:
|
||||
self.log_signal.emit("✅ Item finished. Processing next in queue...")
|
||||
if self.download_thread and isinstance(self.download_thread, QThread):
|
||||
self.download_thread.deleteLater()
|
||||
self.download_thread = None # This is the crucial line
|
||||
self.download_thread = None
|
||||
self.is_finishing = False
|
||||
|
||||
# FIX: Manual release + update flag
|
||||
self.finish_lock.release()
|
||||
lock_held = False
|
||||
|
||||
self._process_next_favorite_download()
|
||||
return
|
||||
# ---------------------------------------------------------
|
||||
|
||||
if self.is_processing_favorites_queue:
|
||||
self.is_processing_favorites_queue = False
|
||||
@ -5618,7 +5826,6 @@ class DownloaderApp (QWidget ):
|
||||
if self.download_thread:
|
||||
if isinstance(self.download_thread, QThread):
|
||||
try:
|
||||
# Disconnect signals to prevent any lingering connections
|
||||
if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log)
|
||||
if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal)
|
||||
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished)
|
||||
@ -5644,6 +5851,7 @@ class DownloaderApp (QWidget ):
|
||||
)
|
||||
self.file_progress_label.setText("")
|
||||
|
||||
# --- RETRY PROMPT BLOCK ---
|
||||
if not cancelled_by_user and self.retryable_failed_files_info:
|
||||
num_failed = len(self.retryable_failed_files_info)
|
||||
reply = QMessageBox.question(self, "Retry Failed Downloads?",
|
||||
@ -5652,7 +5860,11 @@ class DownloaderApp (QWidget ):
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.is_finishing = False
|
||||
|
||||
# FIX: Manual release + update flag
|
||||
self.finish_lock.release()
|
||||
lock_held = False
|
||||
|
||||
self._start_failed_files_retry_session()
|
||||
return
|
||||
else:
|
||||
@ -5663,9 +5875,9 @@ class DownloaderApp (QWidget ):
|
||||
self.cancellation_message_logged_this_session = False
|
||||
self.retryable_failed_files_info.clear()
|
||||
|
||||
|
||||
auto_retry_enabled = self.settings.value(AUTO_RETRY_ON_FINISH_KEY, False, type=bool)
|
||||
|
||||
# --- AUTO RETRY BLOCK ---
|
||||
if not cancelled_by_user and auto_retry_enabled and self.permanently_failed_files_for_dialog:
|
||||
num_files_to_retry = len(self.permanently_failed_files_for_dialog)
|
||||
self.log_signal.emit("=" * 40)
|
||||
@ -5680,7 +5892,6 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self.is_fetcher_thread_running = False
|
||||
|
||||
# --- This is where the post-download action is triggered ---
|
||||
if not cancelled_by_user and not self.is_processing_favorites_queue:
|
||||
self._execute_post_download_action()
|
||||
|
||||
@ -5688,8 +5899,12 @@ class DownloaderApp (QWidget ):
|
||||
self._update_button_states_and_connections()
|
||||
self.cancellation_message_logged_this_session = False
|
||||
self.active_update_profile = None
|
||||
|
||||
finally:
|
||||
# --- Only release if we still hold it ---
|
||||
if lock_held:
|
||||
self.finish_lock.release()
|
||||
# ---------------------------------------------
|
||||
|
||||
def _execute_post_download_action(self):
|
||||
"""Checks the settings and performs the chosen action after downloads complete."""
|
||||
@ -5845,6 +6060,7 @@ class DownloaderApp (QWidget ):
|
||||
'domain_override': domain_override_command,
|
||||
'sfp_threshold': sfp_threshold_command,
|
||||
'handle_unknown_mode': handle_unknown_command,
|
||||
|
||||
'filter_mode':self .get_filter_mode (),
|
||||
'skip_zip':self .skip_zip_checkbox .isChecked (),
|
||||
'use_subfolders':self .use_subfolders_checkbox .isChecked (),
|
||||
@ -5868,13 +6084,11 @@ class DownloaderApp (QWidget ):
|
||||
'target_post_id_from_initial_url':None ,
|
||||
'custom_folder_name':None ,
|
||||
'num_file_threads':1 ,
|
||||
|
||||
# --- START: ADDED COOKIE FIX ---
|
||||
'add_info_in_pdf': self.add_info_in_pdf_setting,
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'cookie_text': self.cookie_text_input.text(),
|
||||
'selected_cookie_file': self.selected_cookie_filepath,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
# --- END: ADDED COOKIE FIX ---
|
||||
|
||||
'manga_date_file_counter_ref':None ,
|
||||
}
|
||||
@ -6576,14 +6790,30 @@ class DownloaderApp (QWidget ):
|
||||
return
|
||||
dialog = EmptyPopupDialog(self.user_data_path, self)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
# --- NEW BATCH UPDATE LOGIC ---
|
||||
|
||||
# --- START OF MODIFICATION ---
|
||||
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
|
||||
self.active_update_profiles_list = dialog.update_profiles_list
|
||||
|
||||
# --- NEW LOGIC: Check if user wants to load settings into UI ---
|
||||
load_settings_requested = getattr(dialog, 'load_settings_into_ui_requested', False)
|
||||
self.override_update_profile_settings = load_settings_requested
|
||||
|
||||
if load_settings_requested:
|
||||
self.log_signal.emit("ℹ️ User requested to edit settings. Loading profile settings into UI...")
|
||||
# Load the settings from the FIRST profile into the main window UI
|
||||
first_profile_settings = self.active_update_profiles_list[0]['data'].get('settings', {})
|
||||
self._load_ui_from_settings_dict(first_profile_settings)
|
||||
# We do NOT start the check immediately if we are editing settings?
|
||||
# Actually, the workflow is: Check for Updates -> Find new posts -> THEN Download.
|
||||
# So we should still proceed with the check, but note that we are in override mode.
|
||||
|
||||
self.log_signal.emit(f"ℹ️ Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...")
|
||||
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
|
||||
self._start_batch_update_check(self.active_update_profiles_list)
|
||||
|
||||
# --- Original logic for adding creators to queue ---
|
||||
self._start_batch_update_check(self.active_update_profiles_list)
|
||||
# --- END OF MODIFICATION ---
|
||||
|
||||
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
|
||||
self.active_update_profile = None # Ensure single update mode is off
|
||||
self.favorite_download_queue.clear()
|
||||
@ -6830,11 +7060,19 @@ class DownloaderApp (QWidget ):
|
||||
main_download_dir = self.dir_input.text().strip()
|
||||
|
||||
should_create_artist_folder = False
|
||||
|
||||
# --- Check for popup selection scope ---
|
||||
if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS:
|
||||
should_create_artist_folder = True
|
||||
# --- Check for global "Artist Folders" scope ---
|
||||
elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS:
|
||||
should_create_artist_folder = True
|
||||
|
||||
# --- NEW: Check for forced folder flag from batch ---
|
||||
if self.current_processing_favorite_item_info.get('force_artist_folder'):
|
||||
should_create_artist_folder = True
|
||||
# ---------------------------------------------------
|
||||
|
||||
if should_create_artist_folder and main_download_dir:
|
||||
folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
|
||||
item_specific_folder_name = clean_folder_name(folder_name_key)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -309,12 +309,16 @@ def setup_ui(main_app):
|
||||
|
||||
# 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:")
|
||||
|
||||
@ -20,7 +20,6 @@
|
||||
│ ├── DejaVuSansCondensed-BoldOblique.ttf
|
||||
│ ├── DejaVuSansCondensed-Oblique.ttf
|
||||
│ └── DejaVuSansCondensed.ttf
|
||||
├── directory_tree.txt
|
||||
├── main.py
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user