mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
1 Commits
v7.8.0
...
48f22c80ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48f22c80ce |
@@ -20,6 +20,7 @@
|
||||
│ ├── DejaVuSansCondensed-BoldOblique.ttf
|
||||
│ ├── DejaVuSansCondensed-Oblique.ttf
|
||||
│ └── DejaVuSansCondensed.ttf
|
||||
├── directory_tree.txt
|
||||
├── main.py
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
@@ -106,5 +107,4 @@
|
||||
│ ├── network_utils.py
|
||||
│ ├── resolution.py
|
||||
│ └── text_utils.py
|
||||
├── structure.txt
|
||||
└── yt-dlp.exe
|
||||
└── yt-dlp.exe
|
||||
@@ -10,9 +10,10 @@ 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...")
|
||||
@@ -38,7 +39,8 @@ 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)
|
||||
|
||||
dl_count, skip_count, chapter_failures = _process_and_download_chapter(
|
||||
# This function now scrapes and downloads simultaneously
|
||||
dl_count, skip_count = _process_and_download_chapter(
|
||||
chapter_url=chapter['url'],
|
||||
save_path=final_save_path,
|
||||
scraper=scraper,
|
||||
@@ -49,22 +51,9 @@ 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:
|
||||
@@ -74,8 +63,9 @@ 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
|
||||
max_retries = 4 # Total number of attempts (1 initial + 3 retries)
|
||||
last_exception = None
|
||||
soup = None
|
||||
|
||||
@@ -87,6 +77,8 @@ 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
|
||||
|
||||
@@ -94,8 +86,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))
|
||||
continue
|
||||
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s
|
||||
continue # Try again
|
||||
|
||||
if last_exception:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
|
||||
@@ -104,36 +96,23 @@ 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 "Artist" in label or "Author" in label:
|
||||
if label in ("Artist", "Author"):
|
||||
a_tag = b_tag.find_next_sibling('a')
|
||||
if a_tag:
|
||||
artist_name = a_tag.get_text(strip=True)
|
||||
break
|
||||
if label == "Artist":
|
||||
break
|
||||
|
||||
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
|
||||
top_level_folder_name = artist_name if artist_name else series_title
|
||||
|
||||
chapter_links = soup.select("div.media a.pull-left.font-w600")
|
||||
if not chapter_links:
|
||||
@@ -145,7 +124,7 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
]
|
||||
chapters_to_process.reverse()
|
||||
|
||||
progress_callback(f" [Hentai2Read] ✅ Found Metadata: '{top_level_folder_name}'")
|
||||
progress_callback(f" [Hentai2Read] ✅ Found Artist/Series: '{top_level_folder_name}'")
|
||||
progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
|
||||
|
||||
return top_level_folder_name, chapters_to_process
|
||||
@@ -157,102 +136,69 @@ 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.
|
||||
Includes RETRY LOGIC and ACTIVE LOGGING.
|
||||
The main thread (producer) scrapes URLs one by one.
|
||||
Worker threads (consumers) download the URLs as they are found.
|
||||
"""
|
||||
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:
|
||||
task = task_queue.get()
|
||||
if task is None:
|
||||
task_queue.task_done()
|
||||
break
|
||||
|
||||
filepath, img_url = task
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
# We log skips to show it's checking files
|
||||
progress_callback(f" -> Skip (Exists): '{filename}'")
|
||||
download_stats['skipped'] += 1
|
||||
task_queue.task_done()
|
||||
continue
|
||||
|
||||
# --- RETRY LOGIC START ---
|
||||
success = False
|
||||
# UNCOMMENTED: Log the start of download so you see activity
|
||||
progress_callback(f" Downloading: '{filename}'...")
|
||||
|
||||
for attempt in range(10): # Try 10 times
|
||||
try:
|
||||
if attempt > 0:
|
||||
progress_callback(f" ⚠️ Retrying '{filename}' (Attempt {attempt+1}/10)...")
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
# Get a task from the queue
|
||||
task = task_queue.get()
|
||||
# The sentinel value to signal the end
|
||||
if task is None:
|
||||
break
|
||||
|
||||
filepath, img_url = task
|
||||
if os.path.exists(filepath):
|
||||
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'")
|
||||
download_stats['skipped'] += 1
|
||||
else:
|
||||
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...")
|
||||
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:
|
||||
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()
|
||||
except Exception as e:
|
||||
progress_callback(f" ❌ Download failed for task. Error: {e}")
|
||||
download_stats['skipped'] += 1
|
||||
finally:
|
||||
task_queue.task_done()
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
|
||||
for _ in range(num_download_threads):
|
||||
executor.submit(downloader_worker)
|
||||
|
||||
page_number = 1
|
||||
progress_callback(" [Hentai2Read] Scanning pages...") # Initial log
|
||||
|
||||
while True:
|
||||
if check_pause_func(): break
|
||||
if page_number > 300:
|
||||
if page_number > 300: # Safety break
|
||||
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):
|
||||
for page_attempt in range(3): # 3 attempts for sub-pages
|
||||
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)
|
||||
time.sleep(1) # Short delay for page scraping retries
|
||||
|
||||
if page_last_exception:
|
||||
raise page_last_exception
|
||||
raise page_last_exception # Give up after 3 tries
|
||||
|
||||
if page_response.history or page_response.status_code != 200:
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
|
||||
@@ -263,7 +209,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 (Last page reached at {page_number}).")
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
|
||||
break
|
||||
|
||||
normalized_img_src = urljoin(page_response.url, img_src)
|
||||
@@ -274,19 +220,15 @@ 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)
|
||||
time.sleep(0.1) # Small delay between scraping pages
|
||||
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" Chapter complete. Processed {page_number - 1} images.")
|
||||
|
||||
return download_stats['downloaded'], download_stats['skipped'], failed_files_list
|
||||
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
|
||||
return download_stats['downloaded'], download_stats['skipped']
|
||||
@@ -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,content"
|
||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
|
||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||
|
||||
max_retries = 3
|
||||
@@ -39,10 +39,10 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
logger(log_message)
|
||||
|
||||
try:
|
||||
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()
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
response.encoding = 'utf-8'
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Handle 403 error on the FIRST page as a rate limit/block
|
||||
@@ -87,10 +87,9 @@ 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}...")
|
||||
|
||||
# FIX: Ensure scraper session is closed after use
|
||||
scraper = None
|
||||
scraper = cloudscraper.create_scraper()
|
||||
|
||||
try:
|
||||
scraper = cloudscraper.create_scraper()
|
||||
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -105,10 +104,6 @@ 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):
|
||||
@@ -120,11 +115,10 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
|
||||
logger(f" Fetching comments: {comments_api_url}")
|
||||
|
||||
try:
|
||||
# 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()
|
||||
response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
response.encoding = 'utf-8'
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"Error fetching comments for post {post_id}: {e}")
|
||||
except ValueError as e:
|
||||
@@ -180,12 +174,10 @@ 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:
|
||||
# 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()
|
||||
|
||||
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
|
||||
direct_response.raise_for_status()
|
||||
direct_response.encoding = 'utf-8'
|
||||
direct_post_data = direct_response.json()
|
||||
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):
|
||||
@@ -319,6 +311,7 @@ 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...")
|
||||
@@ -341,6 +334,7 @@ def download_from_api(
|
||||
break
|
||||
|
||||
try:
|
||||
# 1. Fetch the raw batch of posts
|
||||
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
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}).")
|
||||
@@ -356,6 +350,7 @@ 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}).")
|
||||
@@ -364,8 +359,9 @@ 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
|
||||
break # This break is now correct.
|
||||
|
||||
# 3. Filter the batch against processed IDs
|
||||
posts_batch_to_yield = raw_posts_batch
|
||||
original_count = len(raw_posts_batch)
|
||||
|
||||
@@ -375,17 +371,25 @@ 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
|
||||
@@ -393,6 +397,7 @@ def download_from_api(
|
||||
current_offset += page_size
|
||||
current_page_num += 1
|
||||
time.sleep(0.6)
|
||||
# --- END OF MODIFIED BLOCK ---
|
||||
|
||||
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
|
||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
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,13 +56,12 @@ 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(' .')
|
||||
@@ -133,8 +132,6 @@ 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
|
||||
@@ -208,10 +205,6 @@ 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.")
|
||||
@@ -981,92 +974,6 @@ 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:
|
||||
@@ -1362,8 +1269,6 @@ 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:
|
||||
@@ -1382,7 +1287,6 @@ 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)
|
||||
@@ -1400,8 +1304,6 @@ 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')
|
||||
@@ -1433,43 +1335,23 @@ class PostProcessorWorker:
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
|
||||
# --- Metadata Preparation ---
|
||||
# Prepare all data needed for the info page or JSON dump
|
||||
service_str = self.service
|
||||
user_id_str = str(self.user_id)
|
||||
post_id_str = str(post_id)
|
||||
creator_key = (service_str.lower(), user_id_str)
|
||||
|
||||
# Resolve creator name using the cache passed from main_window
|
||||
creator_name = user_id_str
|
||||
if self.creator_name_cache:
|
||||
creator_name = self.creator_name_cache.get(creator_key, user_id_str)
|
||||
|
||||
common_content_data = {
|
||||
'title': post_title,
|
||||
'published': self.post.get('published') or self.post.get('added'),
|
||||
'service': service_str,
|
||||
'user': user_id_str,
|
||||
'id': post_id_str,
|
||||
'tags': self.post.get('tags'),
|
||||
'original_link': post_page_url,
|
||||
'creator_name': creator_name
|
||||
}
|
||||
|
||||
# --- Single PDF Mode (Save Temp JSON) ---
|
||||
if self.single_pdf_mode:
|
||||
content_data = {
|
||||
'title': post_title,
|
||||
'published': self.post.get('published') or self.post.get('added')
|
||||
}
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data:
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
common_content_data['comments'] = comments_data
|
||||
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
|
||||
common_content_data['content'] = cleaned_text
|
||||
content_data['content'] = cleaned_text
|
||||
|
||||
temp_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -1477,7 +1359,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(common_content_data, f, indent=2)
|
||||
json.dump(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)
|
||||
@@ -1487,67 +1369,82 @@ 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}"
|
||||
|
||||
txt_filename = clean_filename(post_title) + f".{file_extension}"
|
||||
final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename)
|
||||
|
||||
try:
|
||||
os.makedirs(determined_post_save_path_for_history, exist_ok=True)
|
||||
base, ext = os.path.splitext(final_save_path)
|
||||
|
||||
base, ext = os.path.splitext(final_save_path)
|
||||
counter = 1
|
||||
while os.path.exists(final_save_path):
|
||||
final_save_path = f"{base}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
# --- PDF Generation ---
|
||||
if file_extension == 'pdf':
|
||||
# Font setup
|
||||
font_path = ""
|
||||
if self.project_root_dir:
|
||||
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
|
||||
# Add content specific fields for the generator
|
||||
if self.text_only_scope == 'comments':
|
||||
common_content_data['comments_list_for_pdf'] = comments_data
|
||||
if FPDF:
|
||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
||||
pdf = PDF()
|
||||
base_path = self.project_root_dir
|
||||
font_path = ""
|
||||
bold_font_path = ""
|
||||
|
||||
if base_path:
|
||||
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
default_font_family = 'DejaVu'
|
||||
except Exception as font_error:
|
||||
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(0, 10, post_title)
|
||||
pdf.ln(10)
|
||||
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data:
|
||||
self.logger(" -> Skip PDF Creation: No comments to process.")
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
for i, comment in enumerate(comments_data):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, user)
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
if i < len(comments_data) - 1:
|
||||
pdf.ln(5)
|
||||
pdf.cell(0, 0, '', border='T')
|
||||
pdf.ln(5)
|
||||
else:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(0, 7, cleaned_text)
|
||||
|
||||
pdf.output(final_save_path)
|
||||
else:
|
||||
common_content_data['content_text_for_pdf'] = cleaned_text
|
||||
|
||||
# Call the centralized function
|
||||
success = create_individual_pdf(
|
||||
post_data=common_content_data,
|
||||
output_filename=final_save_path,
|
||||
font_path=font_path,
|
||||
add_info_page=self.add_info_in_pdf, # <--- NEW PARAMETER
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise Exception("PDF generation failed (check logs)")
|
||||
|
||||
# --- DOCX Generation ---
|
||||
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)
|
||||
|
||||
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:
|
||||
@@ -1555,20 +1452,9 @@ 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)
|
||||
|
||||
# --- 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
|
||||
|
||||
else: # TXT file
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content_to_write)
|
||||
f.write(cleaned_text)
|
||||
|
||||
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)
|
||||
@@ -1581,7 +1467,6 @@ 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)")
|
||||
|
||||
@@ -145,9 +145,7 @@ def download_and_decrypt_mega_file(info, download_path, logger_func, progress_ca
|
||||
logger_func(f" [Mega] Download for '{file_name}' cancelled before starting.")
|
||||
return
|
||||
|
||||
|
||||
# i tried to make the mega download multipart for big file but it didnt work you can try if you can fix this to make it multipart replace "if true" with this "if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:" to activate multipart
|
||||
if True:
|
||||
if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:
|
||||
logger_func(f" [Mega] Downloading '{file_name}' (Single Stream)...")
|
||||
try:
|
||||
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=0)
|
||||
|
||||
@@ -6,7 +6,7 @@ from packaging.version import parse as parse_version
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
|
||||
# Constants for the updater
|
||||
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi9587/Kemono-Downloader/releases/latest"
|
||||
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi63771/Kemono-Downloader/releases/latest"
|
||||
EXE_NAME = "Kemono.Downloader.exe"
|
||||
|
||||
class UpdateChecker(QThread):
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
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,17 +175,6 @@ 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
|
||||
|
||||
@@ -134,9 +134,7 @@ class SimpCityDownloadThread(QThread):
|
||||
with self.counter_lock: self.total_skip_count += 1
|
||||
return
|
||||
self.progress_signal.emit(f" -> Downloading (Image): '{filename}'...")
|
||||
# --- START MODIFICATION ---
|
||||
response = session.get(job['url'], stream=True, timeout=180, headers={'Referer': self.start_url})
|
||||
# --- END MODIFICATION ---
|
||||
response = session.get(job['url'], stream=True, timeout=90, headers={'Referer': self.start_url})
|
||||
response.raise_for_status()
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
@@ -229,9 +227,7 @@ class SimpCityDownloadThread(QThread):
|
||||
else:
|
||||
self.progress_signal.emit(f" -> Downloading: '{filename}'...")
|
||||
headers = file_data.get('headers', {'Referer': source_url})
|
||||
# --- START MODIFICATION ---
|
||||
response = session.get(file_data.get('url'), stream=True, timeout=180, headers=headers)
|
||||
# --- END MODIFICATION ---
|
||||
response = session.get(file_data.get('url'), stream=True, timeout=90, headers=headers)
|
||||
response.raise_for_status()
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
@@ -254,7 +250,6 @@ 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()
|
||||
|
||||
@@ -289,10 +284,8 @@ class SimpCityDownloadThread(QThread):
|
||||
enriched_jobs = self._get_enriched_jobs(jobs)
|
||||
if enriched_jobs:
|
||||
for job in enriched_jobs:
|
||||
if job['type'] == 'image':
|
||||
if self.should_dl_images: self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
|
||||
if job['type'] == 'image': 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
|
||||
@@ -305,30 +298,16 @@ class SimpCityDownloadThread(QThread):
|
||||
try:
|
||||
page_title, jobs_on_page, final_url = fetch_single_simpcity_page(page_url, self._log_interceptor, cookies=self.cookies)
|
||||
|
||||
# --- START: MODIFIED REDIRECT LOGIC ---
|
||||
if final_url != page_url:
|
||||
self.progress_signal.emit(f" -> Redirect detected from {page_url} to {final_url}")
|
||||
try:
|
||||
req_page_match = re.search(r'/page-(\d+)', page_url)
|
||||
final_page_match = re.search(r'/page-(\d+)', final_url)
|
||||
|
||||
if req_page_match:
|
||||
req_page_num = int(req_page_match.group(1))
|
||||
|
||||
# Scenario 1: Redirect to an earlier page (e.g., page-11 -> page-10)
|
||||
if final_page_match and int(final_page_match.group(1)) < req_page_num:
|
||||
self.progress_signal.emit(f" -> Redirected to an earlier page ({final_page_match.group(0)}). Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
# Scenario 2: Redirect to base URL (e.g., page-11 -> /)
|
||||
# We check req_page_num > 1 because page-1 often redirects to base URL, which is normal.
|
||||
elif not final_page_match and req_page_num > 1:
|
||||
self.progress_signal.emit(f" -> Redirected to base thread URL. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
if req_page_match and final_page_match and int(final_page_match.group(1)) < int(req_page_match.group(1)):
|
||||
self.progress_signal.emit(" -> Redirected to an earlier page. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
except (ValueError, TypeError):
|
||||
pass # Ignore parsing errors
|
||||
# --- END: MODIFIED REDIRECT LOGIC ---
|
||||
pass
|
||||
|
||||
if end_of_thread:
|
||||
page_fetch_successful = True; break
|
||||
@@ -337,43 +316,25 @@ class SimpCityDownloadThread(QThread):
|
||||
self.progress_signal.emit(f" -> Page {page_counter} is invalid or has no title. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
elif not jobs_on_page:
|
||||
self.progress_signal.emit(f" -> Page {page_counter} has no content. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
else:
|
||||
new_jobs = [job for job in jobs_on_page if job.get('url') not in self.processed_job_urls]
|
||||
if not new_jobs and page_counter > 1:
|
||||
self.progress_signal.emit(f" -> Page {page_counter} contains no new content. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
else:
|
||||
enriched_jobs = self._get_enriched_jobs(new_jobs)
|
||||
if not enriched_jobs and not new_jobs:
|
||||
# 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':
|
||||
if self.should_dl_images: self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
|
||||
for job in enriched_jobs:
|
||||
self.processed_job_urls.add(job.get('url'))
|
||||
if job['type'] == 'image': 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]:
|
||||
self.progress_signal.emit(f" -> Page {page_counter} returned {e.response.status_code}. Reached end of thread.")
|
||||
end_of_thread = True; break
|
||||
elif e.response.status_code == 429:
|
||||
self.progress_signal.emit(f" -> Rate limited (429). Waiting...")
|
||||
time.sleep(5 * (retries + 2)); retries += 1
|
||||
else:
|
||||
self.progress_signal.emit(f" -> HTTP Error {e.response.status_code} on page {page_counter}. Stopping crawl.")
|
||||
end_of_thread = True; break
|
||||
if e.response.status_code in [403, 404]: end_of_thread = True; break
|
||||
elif e.response.status_code == 429: time.sleep(5 * (retries + 2)); retries += 1
|
||||
else: end_of_thread = True; break
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" Stopping crawl due to error on page {page_counter}: {e}"); end_of_thread = True; break
|
||||
if not page_fetch_successful and not end_of_thread:
|
||||
self.progress_signal.emit(f" -> Failed to fetch page {page_counter} after {MAX_RETRIES} attempts. Stopping crawl.")
|
||||
end_of_thread = True
|
||||
if not page_fetch_successful and not end_of_thread: end_of_thread = True
|
||||
if not end_of_thread: page_counter += 1
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f"❌ A critical error occurred during the main fetch phase: {e}")
|
||||
|
||||
@@ -7,6 +7,7 @@ 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",
|
||||
@@ -18,10 +19,7 @@ class CustomFilenameDialog(QDialog):
|
||||
"name": "name"
|
||||
}
|
||||
|
||||
# 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):
|
||||
def __init__(self, current_format, current_date_format, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Custom Filename Format")
|
||||
self.setMinimumWidth(500)
|
||||
@@ -33,11 +31,9 @@ class CustomFilenameDialog(QDialog):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# --- Description ---
|
||||
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 = QLabel(
|
||||
"Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format."
|
||||
)
|
||||
description_label.setWordWrap(True)
|
||||
layout.addWidget(description_label)
|
||||
|
||||
@@ -46,20 +42,15 @@ 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}")
|
||||
|
||||
self.format_input.setPlaceholderText("e.g., {published} {title} {id}")
|
||||
layout.addWidget(self.format_input)
|
||||
|
||||
# --- Date Format Input ---
|
||||
date_format_label = QLabel("Date Format (for {published}):")
|
||||
date_format_label = QLabel("Date Format (for {added}, {published}, {edited}):")
|
||||
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")
|
||||
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD or DD-MM-YYYY")
|
||||
layout.addWidget(self.date_format_input)
|
||||
|
||||
# --- Available Keys Display ---
|
||||
@@ -71,20 +62,7 @@ class CustomFilenameDialog(QDialog):
|
||||
|
||||
for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
|
||||
key_button = QPushButton(f"{{{display_key}}}")
|
||||
|
||||
# --- 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
|
||||
# Use a lambda to pass the correct internal key when the button is clicked
|
||||
key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
|
||||
keys_layout.addWidget(key_button)
|
||||
keys_layout.addStretch()
|
||||
@@ -103,7 +81,9 @@ 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):
|
||||
return self.date_format_input.text().strip()
|
||||
"""Returns the date format string from its input field."""
|
||||
return self.date_format_input.text().strip()
|
||||
|
||||
@@ -140,7 +140,7 @@ class EmptyPopupDialog (QDialog ):
|
||||
SCOPE_CREATORS ="Creators"
|
||||
|
||||
|
||||
def __init__ (self ,user_data_path ,parent_app_ref ,parent =None ):
|
||||
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
|
||||
super ().__init__ (parent )
|
||||
self.parent_app = parent_app_ref
|
||||
|
||||
@@ -148,7 +148,7 @@ class EmptyPopupDialog (QDialog ):
|
||||
|
||||
self.setMinimumSize(int(400 * scale_factor), int(300 * scale_factor))
|
||||
self.current_scope_mode = self.SCOPE_CREATORS
|
||||
self.user_data_path = user_data_path
|
||||
self .app_base_dir =app_base_dir
|
||||
|
||||
app_icon =get_app_icon_object ()
|
||||
if app_icon and not app_icon .isNull ():
|
||||
@@ -156,9 +156,6 @@ 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
|
||||
@@ -339,14 +336,11 @@ class EmptyPopupDialog (QDialog ):
|
||||
"""
|
||||
# --- NEW BEHAVIOR ---
|
||||
# Pass the app_base_dir and a reference to the main app (for translations/theme)
|
||||
dialog = UpdateCheckDialog(self.user_data_path, self.parent_app, self)
|
||||
dialog = UpdateCheckDialog(self.app_base_dir, self.parent_app, self)
|
||||
|
||||
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 ---
|
||||
@@ -1058,4 +1052,4 @@ class EmptyPopupDialog (QDialog ):
|
||||
else :
|
||||
if unique_key in self .globally_selected_creators :
|
||||
del self .globally_selected_creators [unique_key ]
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
@@ -7,8 +7,7 @@ import sys
|
||||
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
|
||||
QTabWidget, QWidget, QFileDialog # Added QFileDialog
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit
|
||||
)
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
@@ -112,7 +111,7 @@ class CountdownMessageBox(QDialog):
|
||||
class FutureSettingsDialog(QDialog):
|
||||
"""
|
||||
A dialog for managing application-wide settings like theme, language,
|
||||
and display options, using a tabbed layout.
|
||||
and display options, with an organized layout.
|
||||
"""
|
||||
def __init__(self, parent_app_ref, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -125,9 +124,8 @@ class FutureSettingsDialog(QDialog):
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||
# Use a more balanced aspect ratio
|
||||
scale_factor = screen_height / 1000.0
|
||||
base_min_w, base_min_h = 480, 420 # Wider, less tall
|
||||
scale_factor = screen_height / 800.0
|
||||
base_min_w, base_min_h = 420, 520 # Increased height for new options
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
@@ -139,105 +137,67 @@ class FutureSettingsDialog(QDialog):
|
||||
def _init_ui(self):
|
||||
"""Initializes all UI components and layouts for the dialog."""
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# --- Create Tab Widget ---
|
||||
self.tab_widget = QTabWidget()
|
||||
main_layout.addWidget(self.tab_widget)
|
||||
|
||||
# --- Create Tabs ---
|
||||
self.display_tab = QWidget()
|
||||
self.downloads_tab = QWidget()
|
||||
self.updates_tab = QWidget()
|
||||
|
||||
# Add tabs to the widget
|
||||
self.tab_widget.addTab(self.display_tab, "Display")
|
||||
self.tab_widget.addTab(self.downloads_tab, "Downloads")
|
||||
self.tab_widget.addTab(self.updates_tab, "Updates")
|
||||
|
||||
# --- Populate Display Tab ---
|
||||
display_tab_layout = QVBoxLayout(self.display_tab)
|
||||
self.display_group_box = QGroupBox()
|
||||
display_layout = QGridLayout(self.display_group_box)
|
||||
self.interface_group_box = QGroupBox()
|
||||
interface_layout = QGridLayout(self.interface_group_box)
|
||||
|
||||
self.theme_label = QLabel()
|
||||
self.theme_toggle_button = QPushButton()
|
||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||
display_layout.addWidget(self.theme_label, 0, 0)
|
||||
display_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
interface_layout.addWidget(self.theme_label, 0, 0)
|
||||
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
|
||||
self.ui_scale_label = QLabel()
|
||||
self.ui_scale_combo_box = QComboBox()
|
||||
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
display_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
display_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
interface_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
|
||||
self.language_label = QLabel()
|
||||
self.language_combo_box = QComboBox()
|
||||
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
|
||||
display_layout.addWidget(self.language_label, 2, 0)
|
||||
display_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
interface_layout.addWidget(self.language_label, 2, 0)
|
||||
interface_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
|
||||
main_layout.addWidget(self.interface_group_box)
|
||||
|
||||
self.download_window_group_box = QGroupBox()
|
||||
download_window_layout = QGridLayout(self.download_window_group_box)
|
||||
|
||||
self.window_size_label = QLabel()
|
||||
self.resolution_combo_box = QComboBox()
|
||||
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
display_layout.addWidget(self.window_size_label, 3, 0)
|
||||
display_layout.addWidget(self.resolution_combo_box, 3, 1)
|
||||
|
||||
display_tab_layout.addWidget(self.display_group_box)
|
||||
display_tab_layout.addStretch(1) # Push content to the top
|
||||
|
||||
# --- Populate Downloads Tab ---
|
||||
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
|
||||
self.download_settings_group_box = QGroupBox()
|
||||
download_settings_layout = QGridLayout(self.download_settings_group_box)
|
||||
download_window_layout.addWidget(self.window_size_label, 0, 0)
|
||||
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
|
||||
|
||||
self.default_path_label = QLabel()
|
||||
self.save_path_button = QPushButton()
|
||||
self.save_path_button.clicked.connect(self._save_settings)
|
||||
download_settings_layout.addWidget(self.default_path_label, 0, 0)
|
||||
download_settings_layout.addWidget(self.save_path_button, 0, 1)
|
||||
|
||||
self.post_download_action_label = QLabel()
|
||||
self.post_download_action_combo = QComboBox()
|
||||
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
|
||||
download_settings_layout.addWidget(self.post_download_action_label, 1, 0)
|
||||
download_settings_layout.addWidget(self.post_download_action_combo, 1, 1)
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
self.date_prefix_format_label = QLabel()
|
||||
self.date_prefix_format_input = QLineEdit()
|
||||
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
|
||||
download_settings_layout.addWidget(self.date_prefix_format_label, 2, 0)
|
||||
download_settings_layout.addWidget(self.date_prefix_format_input, 2, 1)
|
||||
download_window_layout.addWidget(self.date_prefix_format_label, 2, 0)
|
||||
download_window_layout.addWidget(self.date_prefix_format_input, 2, 1)
|
||||
|
||||
self.post_download_action_label = QLabel()
|
||||
self.post_download_action_combo = QComboBox()
|
||||
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
|
||||
download_window_layout.addWidget(self.post_download_action_label, 3, 0)
|
||||
download_window_layout.addWidget(self.post_download_action_combo, 3, 1)
|
||||
|
||||
self.save_creator_json_checkbox = QCheckBox()
|
||||
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
|
||||
download_settings_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2)
|
||||
download_window_layout.addWidget(self.save_creator_json_checkbox, 4, 0, 1, 2)
|
||||
|
||||
self.fetch_first_checkbox = QCheckBox()
|
||||
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
||||
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
|
||||
download_window_layout.addWidget(self.fetch_first_checkbox, 5, 0, 1, 2)
|
||||
|
||||
# --- START: Add new Load/Save buttons ---
|
||||
settings_file_layout = QHBoxLayout()
|
||||
self.load_settings_button = QPushButton()
|
||||
self.save_settings_button = QPushButton()
|
||||
settings_file_layout.addWidget(self.load_settings_button)
|
||||
settings_file_layout.addWidget(self.save_settings_button)
|
||||
settings_file_layout.addStretch(1)
|
||||
|
||||
# Add this new layout to the grid
|
||||
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2) # Row 5, span 2 cols
|
||||
|
||||
# Connect signals
|
||||
self.load_settings_button.clicked.connect(self._handle_load_settings)
|
||||
self.save_settings_button.clicked.connect(self._handle_save_settings)
|
||||
# --- END: Add new Load/Save buttons ---
|
||||
main_layout.addWidget(self.download_window_group_box)
|
||||
|
||||
downloads_tab_layout.addWidget(self.download_settings_group_box)
|
||||
downloads_tab_layout.addStretch(1) # Push content to the top
|
||||
|
||||
# --- Populate Updates Tab ---
|
||||
updates_tab_layout = QVBoxLayout(self.updates_tab)
|
||||
self.update_group_box = QGroupBox()
|
||||
update_layout = QGridLayout(self.update_group_box)
|
||||
self.version_label = QLabel()
|
||||
@@ -247,39 +207,29 @@ class FutureSettingsDialog(QDialog):
|
||||
update_layout.addWidget(self.version_label, 0, 0)
|
||||
update_layout.addWidget(self.update_status_label, 0, 1)
|
||||
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
||||
|
||||
updates_tab_layout.addWidget(self.update_group_box)
|
||||
updates_tab_layout.addStretch(1) # Push content to the top
|
||||
main_layout.addWidget(self.update_group_box)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
|
||||
# --- OK Button (outside tabs) ---
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch(1)
|
||||
self.ok_button = QPushButton()
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
|
||||
def _retranslate_ui(self):
|
||||
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
|
||||
|
||||
# --- Tab Titles ---
|
||||
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display"))
|
||||
self.tab_widget.setTabText(1, self._tr("settings_tab_downloads", "Downloads"))
|
||||
self.tab_widget.setTabText(2, self._tr("settings_tab_updates", "Updates"))
|
||||
|
||||
# --- Display Tab ---
|
||||
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
|
||||
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
|
||||
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
|
||||
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
||||
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
|
||||
# --- Downloads Tab ---
|
||||
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
|
||||
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
|
||||
# Update placeholder to include {post}
|
||||
self.date_prefix_format_input.setPlaceholderText(self._tr("date_prefix_format_placeholder", "e.g., YYYY-MM-DD {post} {postid}"))
|
||||
# Add the tooltip to explain usage
|
||||
self.date_prefix_format_input.setToolTip(self._tr(
|
||||
"date_prefix_format_tooltip",
|
||||
"Create a custom folder name using placeholders:\n"
|
||||
@@ -288,32 +238,25 @@ class FutureSettingsDialog(QDialog):
|
||||
"• {postid}: for the post's unique ID\n\n"
|
||||
"Example: {post} [{postid}] [YYYY-MM-DD]"
|
||||
))
|
||||
|
||||
|
||||
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||
|
||||
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
||||
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
|
||||
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
|
||||
self._update_theme_toggle_button_text()
|
||||
self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
|
||||
self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions."))
|
||||
|
||||
# --- START: Add new button text ---
|
||||
self.load_settings_button.setText(self._tr("load_settings_button", "Load Settings..."))
|
||||
self.load_settings_button.setToolTip(self._tr("load_settings_tooltip", "Load all download settings from a .json file."))
|
||||
self.save_settings_button.setText(self._tr("save_settings_button", "Save Settings..."))
|
||||
self.save_settings_button.setToolTip(self._tr("save_settings_tooltip", "Save all current download settings to a .json file."))
|
||||
# --- END: Add new button text ---
|
||||
|
||||
# --- Updates Tab ---
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
||||
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
||||
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
|
||||
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
|
||||
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
||||
|
||||
# --- General ---
|
||||
self._update_theme_toggle_button_text()
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
# --- Load Data ---
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
self._populate_post_download_action_combo()
|
||||
@@ -388,38 +331,7 @@ class FutureSettingsDialog(QDialog):
|
||||
def _apply_theme(self):
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
base_stylesheet = get_dark_theme(scale)
|
||||
|
||||
# --- START: Tab Styling Fix ---
|
||||
tab_stylesheet = """
|
||||
QTabWidget::pane {
|
||||
border-top: 1px solid #444;
|
||||
margin-top: -1px; /* Overlap with tab bar */
|
||||
background-color: #2D2D2D;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background-color: #3D3D3D;
|
||||
color: #BBBBBB;
|
||||
border: 1px solid #444;
|
||||
border-bottom: none; /* No bottom border for tabs */
|
||||
padding: 6px 12px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #2D2D2D; /* Same as pane background */
|
||||
color: #EEEEEE;
|
||||
border-bottom: 1px solid #2D2D2D; /* Hides the pane top border */
|
||||
margin-bottom: -1px; /* Pulls tab down to cover pane border */
|
||||
}
|
||||
QTabBar::tab:!selected:hover {
|
||||
background-color: #4A4A4A;
|
||||
}
|
||||
"""
|
||||
# --- END: Tab Styling Fix ---
|
||||
|
||||
self.setStyleSheet(base_stylesheet + tab_stylesheet)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
|
||||
@@ -578,98 +490,4 @@ class FutureSettingsDialog(QDialog):
|
||||
if path_saved or cookie_saved or token_saved:
|
||||
QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
|
||||
else:
|
||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||
|
||||
# --- START: New functions for Save/Load ---
|
||||
def _get_settings_dir(self):
|
||||
"""Helper to get a consistent directory for saving/loading profiles."""
|
||||
if hasattr(self.parent_app, 'user_data_path'):
|
||||
# We use 'user_data_path' which should point to 'appdata'
|
||||
settings_dir = os.path.join(self.parent_app.user_data_path, "settings_profiles")
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
return settings_dir
|
||||
# Fallback if user_data_path isn't available
|
||||
return QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation)
|
||||
|
||||
def _handle_save_settings(self):
|
||||
"""
|
||||
Calls the main app to get all settings, then saves them to a user-chosen JSON file.
|
||||
"""
|
||||
if not hasattr(self.parent_app, '_get_current_ui_settings_as_dict'):
|
||||
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
|
||||
self._tr("settings_missing_save_func_error", "Parent application is missing the required save function."))
|
||||
return
|
||||
|
||||
settings_dir = self._get_settings_dir()
|
||||
filepath, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
self._tr("save_settings_dialog_title", "Save Settings Profile"),
|
||||
settings_dir,
|
||||
self._tr("json_files_filter", "JSON Files (*.json)")
|
||||
)
|
||||
|
||||
if filepath:
|
||||
if not filepath.endswith('.json'):
|
||||
filepath += '.json'
|
||||
|
||||
try:
|
||||
# Get all settings from the main window
|
||||
settings_data = self.parent_app._get_current_ui_settings_as_dict()
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings_data, f, indent=2)
|
||||
|
||||
QMessageBox.information(self,
|
||||
self._tr("save_settings_success_title", "Settings Saved"),
|
||||
self._tr("save_settings_success_msg", "Settings successfully saved to:\n{filename}")
|
||||
.format(filename=os.path.basename(filepath)))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,
|
||||
self._tr("save_settings_error_title", "Error Saving Settings"),
|
||||
str(e))
|
||||
|
||||
def _handle_load_settings(self):
|
||||
"""
|
||||
Lets the user pick a JSON file, loads it, and applies the settings to the main app.
|
||||
"""
|
||||
if not hasattr(self.parent_app, '_load_ui_from_settings_dict') or \
|
||||
not hasattr(self.parent_app, '_update_all_ui_states'):
|
||||
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
|
||||
self._tr("settings_missing_load_func_error", "Parent application is missing the required load functions."))
|
||||
return
|
||||
|
||||
settings_dir = self._get_settings_dir()
|
||||
filepath, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
self._tr("load_settings_dialog_title", "Load Settings Profile"),
|
||||
settings_dir,
|
||||
self._tr("json_files_filter", "JSON Files (*.json)")
|
||||
)
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
settings_data = json.load(f)
|
||||
|
||||
if not isinstance(settings_data, dict):
|
||||
raise ValueError(self._tr("settings_invalid_json_error", "File is not a valid settings dictionary."))
|
||||
|
||||
# Apply all settings to the main window
|
||||
self.parent_app._load_ui_from_settings_dict(settings_data)
|
||||
|
||||
# Refresh the main window UI to show changes
|
||||
self.parent_app._update_all_ui_states()
|
||||
|
||||
QMessageBox.information(self,
|
||||
self._tr("load_settings_success_title", "Settings Loaded"),
|
||||
self._tr("load_settings_success_msg", "Successfully loaded settings from:\n{filename}")
|
||||
.format(filename=os.path.basename(filepath)))
|
||||
|
||||
# Close the settings dialog after loading
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,
|
||||
self._tr("load_settings_error_title", "Error Loading Settings"),
|
||||
str(e))
|
||||
# --- END: New functions for Save/Load ---
|
||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||
@@ -6,6 +6,7 @@ from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
|
||||
)
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -25,8 +26,7 @@ class TourStepWidget(QWidget):
|
||||
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
# Use a consistent color for titles regardless of theme
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB; padding-bottom: 15px;")
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
@@ -41,456 +41,17 @@ class TourStepWidget(QWidget):
|
||||
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
content_label.setTextFormat(Qt.RichText)
|
||||
content_label.setOpenExternalLinks(True)
|
||||
# Set a base line-height and color
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.5;")
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
|
||||
scroll_area.setWidget(content_label)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
|
||||
class HelpGuideDialog(QDialog):
|
||||
"""A multi-page dialog for displaying the feature guide with a navigation list."""
|
||||
|
||||
def __init__(self, steps_data, parent_app, parent=None):
|
||||
super().__init__(parent_app)
|
||||
|
||||
self.parent_app = parent_app # This is the main_window instance
|
||||
|
||||
|
||||
self.steps_data = [
|
||||
("Welcome!",
|
||||
"""
|
||||
<p style='font-size: 12pt;'>Welcome to the Kemono Downloader! This guide will walk you through the key features to get you started.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Wide Range of Support</h3>
|
||||
<p>This application provides full, direct download support for several popular sites, including:</p>
|
||||
<ul>
|
||||
<li>Kemono</li>
|
||||
<li>Coomer</li>
|
||||
<li>Bunkr</li>
|
||||
<li>Erome</li>
|
||||
<li>Saint2.su</li>
|
||||
<li>nhentai.net/</li>
|
||||
<li>fap-nation.org/</li>
|
||||
<li>Discord</li>
|
||||
<li>allporncomic.com</li>
|
||||
<li>allporncomic.com</li>
|
||||
<li>hentai2read.com</li>
|
||||
<li>mangadex.org</li>
|
||||
<li>Simpcity</li>
|
||||
<li>gelbooru.com</li>
|
||||
<li>Toonily.com</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Powerful Batch Mode</h3>
|
||||
<p>Save time by downloading hundreds of URLs at once. Simply type <b>nhentai.net</b> or <b>saint2.su</b> into the URL bar. The app will look for a <b>nhentai.txt</b> or <b>saint2.su.txt</b> file in your 'appdata' folder and process all the URLs inside it.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Advanced Discord Support</h3>
|
||||
<p>Go beyond simple file downloading. The app can connect directly to the Discord API to:</p>
|
||||
<ul>
|
||||
<li>Download all files from a specific channel.</li>
|
||||
<li>Save an entire channel's message history as a fully formatted PDF.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Advanced Filtering",
|
||||
"""
|
||||
<p>Control exactly what content you download, from broad categories to specific keywords.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Content Type Filters</h3>
|
||||
<p>These radio buttons let you select the main <i>type</i> of content you want:</p>
|
||||
<ul>
|
||||
<li><b>All:</b> Downloads everything (default).</li>
|
||||
<li><b>Images/GIFs:</b> Only downloads static images and GIFs.</li>
|
||||
<li><b>Videos:</b> Only downloads video files (MP4, WEBM, MOV, etc.).</li>
|
||||
<li><b>Only Archives:</b> Exclusively downloads .zip and .rar files.</li>
|
||||
<li><b>Only Links:</b> Extracts external links (Mega, Google Drive) from post descriptions instead of downloading.</li>
|
||||
<li><b>Only Audio:</b> Only downloads audio files (MP3, WAV, etc.).</li>
|
||||
<li><b>More:</b> Opens a dialog to download post descriptions or comments as text/PDF.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Character Filtering</h3>
|
||||
<p>The <b>"Filter by Character(s)"</b> input is your most powerful tool for targeting content.</p>
|
||||
<ul>
|
||||
<li><b>Basic Use:</b> Enter names, separated by commas (e.g., <code>Tifa, Aerith</code>). This will create folders for "Tifa" and "Aerith" and download posts matching those names.</li>
|
||||
<li><b>Grouped Aliases:</b> Use parentheses to group aliases for a single character (e.g., <code>(Tifa, Lockhart)</code>). This still creates a "Tifa" folder, but it will also match posts that just say "Lockhart".</li>
|
||||
</ul>
|
||||
<p>The <b>"Filter: [Scope]"</b> button changes <i>what</i> is scanned:</p>
|
||||
<ul>
|
||||
<li><b>Filter: Title (Default):</b> Scans only the post's main title.</li>
|
||||
<li><b>Filter: Files:</b> Scans the <i>filenames</i> within the post.</li>
|
||||
<li><b>Filter: Both:</b> Scans both the title and the filenames.</li>
|
||||
<li><b>Filter: Comments (Beta):</b> Scans the post's comment section for the keywords.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Skip Filters (Avoid Content)</h3>
|
||||
<p>The <b>"Skip with Words"</b> input lets you avoid content you don't want.</p>
|
||||
<p>The <b>"Scope: [Scope]"</b> button changes <i>how</i> it skips:</p>
|
||||
<ul>
|
||||
<li><b>Scope: Posts (Default):</b> Skips the <i>entire post</i> if the post's title contains a skip word (e.g., <code>WIP, sketch</code>).</li>
|
||||
<li><b>Scope: Files:</b> Scans and skips <i>individual files</i> if their filename contains a skip word.</li>
|
||||
<li><b>Scope: Both:</b> Skips the post if the title matches, and if not, still checks individual files.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Other Content Options</h3>
|
||||
<ul>
|
||||
<li><b>Skip .zip:</b> A quick toggle to ignore all archive files.</li>
|
||||
<li><b>Download Thumbnails Only:</b> Downloads the small preview image instead of the full-resolution file.</li>
|
||||
<li><b>Scan Content for Images:</b> Scans the post's text description for <code><img></code> tags. Useful for embedded images not in the post's attachment list.</li>
|
||||
<li><b>Keep Duplicates:</b> By default, the app skips files with identical content (hash). Check this to open a dialog and configure it to keep duplicate files.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Filename Control</h3>
|
||||
<p>The <b>"Remove Words from name"</b> input cleans up filenames. Any text you enter here (comma-separated) will be removed from the final saved filename (e.g., <code>patreon, exclusive</code>).</p>
|
||||
"""),
|
||||
|
||||
("Folder Management (Known.txt)",
|
||||
"""
|
||||
<p>This feature, enabled by the <b>"Separate Folders by Known.txt"</b> checkbox, automatically sorts your downloads. It's designed mainly for <b>Kemono</b>, where creators often tag posts with character names in the title.</p>
|
||||
|
||||
<p>When you download from a creator, this feature checks each <b>post title</b> against your `Known.txt` list. If a name matches, a folder is created for that name, and all posts from that creator mentioning the name will be <b>grouped together</b> in that single folder.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Folder Naming Priority</h3>
|
||||
<p>When "Separate Folders" is checked, the app uses this priority to name folders:</p>
|
||||
<ol>
|
||||
<li><b>Character Filter:</b> If you use the <b>"Filter by Character(s)"</b> input (e.g., <code>Tifa</code>), that name is <b>always</b> used as the folder name. This overrides all other rules.</li>
|
||||
<li><b>Known.txt (Post Title):</b> If no filter is used, it checks the <b>post's title</b> for a name in `Known.txt`. (This is the most common use case).</li>
|
||||
<li><b>Known.txt (Filename):</b> If the title doesn't match, it checks all <b>filenames</b> in the post for a match in `Known.txt`.</li>
|
||||
<li><b>Fallback:</b> If no match is found, it creates a generic folder from the post's title.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Editing Your Known.txt File</h3>
|
||||
<p>You can manage this list using the panel on the right of the main window or by clicking <b>"Open Known.txt"</b> to edit it directly. There are two formats:</p>
|
||||
<ul>
|
||||
<li><b>Simple Name:</b><br>
|
||||
<code>Tifa</code><br>
|
||||
This creates a folder named "Tifa" and matches posts/files named "Tifa".
|
||||
</li>
|
||||
<br>
|
||||
<li><b>Grouped Aliases:</b><br>
|
||||
<code>(Tifa, Lockhart)</code><br>
|
||||
This is the most powerful format. It creates a folder named <b>"Tifa Lockhart"</b> and will match posts/files that contain either "Tifa" <i>or</i> "Lockhart". This is perfect for characters with multiple names.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Important Note:</h3>
|
||||
<p>This automatic sorting <b>only works if the creator includes the character names or keywords in the post title</b> (or filename). If they don't, the app has no way of knowing how to sort the post, and it will fall back to a generic folder name.</p>
|
||||
"""),
|
||||
|
||||
("Renaming Mode",
|
||||
"""
|
||||
<p>This mode is designed for downloading comics, manga, or any multi-file post where you need files to be in a specific, sequential order. When active, it downloads posts from <b>oldest to newest</b>.</p>
|
||||
|
||||
<p>Activate it by checking the <b>"Renaming Mode"</b> checkbox. This reveals a new button: <b>"Name: [Style]"</b>. Clicking this button cycles through all available naming conventions.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Available Naming Styles</h3>
|
||||
<ul>
|
||||
<li><b>Post Title:</b> (Default) Files are named after the post's title, with a number for multi-file posts (e.g., <code>My Comic Page_1.jpg</code>, <code>My Comic Page_2.jpg</code>).</li>
|
||||
|
||||
<li><b>Date + Original:</b> Prepends the post's date to the original filename (e.g., <code>2025-11-16_original_file_name.jpg</code>).</li>
|
||||
|
||||
<li><b>Date + Title:</b> Prepends the date to the post title (e.g., <code>2025-11-16_My Comic Page_1.jpg</code>).</li>
|
||||
|
||||
<li><b>Post ID:</b> Names files using the post's unique ID and the file index (e.g., <code>9876543_0.jpg</code>, <code>9876543_1.jpg</code>).</li>
|
||||
|
||||
<li><b>Date Based:</b> Renames all files to a simple, sequential number (e.g., <code>001.jpg</code>, <code>002.jpg</code>). You can add a prefix in the text box that appears (e.g., "Chapter 1 " to get <code>Chapter 1 001.jpg</code>).
|
||||
<br><b style='color: #f0ad4e;'>Note: This mode disables multithreading to guarantee correct file order.</b></li>
|
||||
|
||||
<li><b>Title + G.Num (Global Numbering):</b> Names files by title, but with a *global* counter (e.g., <code>Post A_001.jpg</code>, <code>Post B_002.jpg</code>).
|
||||
<br><b style='color: #f0ad4e;'>Note: This mode also disables multithreading.</b></li>
|
||||
|
||||
<li><b>Custom:</b> Lets you design your own filename using a format string. A <b>"..."</b> button will appear to open the custom format dialog.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Custom Format Placeholders</h3>
|
||||
<p>When using the "Custom" style, you can use these placeholders (click the buttons in the dialog to add them):</p>
|
||||
<ul>
|
||||
<li><code>{id}</code> - The unique ID of the post.</li>
|
||||
<li><code>{creator_name}</code> - The creator's name.</li>
|
||||
<li><code>{service}</code> - The service (e.g., Patreon, Pixiv Fanbox, etc).</li>
|
||||
<li><code>{title}</code> - The title of the post.</li>
|
||||
<li><code>{added}</code> - Date the post was added.</li>
|
||||
<li><code>{published}</code> - Date the post was published.</li>
|
||||
<li><code>{edited}</code> - Date the post was last edited.</li>
|
||||
<li><code>{name}</code> - The original name of the file.</li>
|
||||
</ul>
|
||||
<p>You can also set a custom <b>Date Format</b> (e.g., <code>YYYY-MM-DD</code>) that will apply to the {added}, {published}, and {edited} placeholders.</p>
|
||||
"""),
|
||||
|
||||
("Batch Downloading",
|
||||
"""
|
||||
<p>This feature allows you to download hundreds of URLs from a text file, which is much faster than queuing them one by one.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>How It Works (Step-by-Step)</h3>
|
||||
<ol>
|
||||
<li><b>Find your 'appdata' folder:</b> This is in the same directory as the downloader's <code>.exe</code> file.</li>
|
||||
<li><b>Create a .txt file:</b> Inside the 'appdata' folder, create a text file for the site you want to batch from. The name must be exact. (eg.. nhentai.txt, hentai2read.txt, etc.. )</li>
|
||||
<li><b>Add URLs:</b> Open the <code>.txt</code> file and paste one download URL on each line. Save the file.</li>
|
||||
<li><b>Start the Batch:</b> In the downloader's main URL bar, type the <b>site's domain name</b> (e.g., <code>nhentai.net</code>) and click "Start Download".</li>
|
||||
</ol>
|
||||
<p>The app will automatically find your text file, read all the URLs, and download them sequentially.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Supported Sites and Filenames</h3>
|
||||
<p>The <code>.txt</code> file name must match the site you are triggering:</p>
|
||||
<ul>
|
||||
<li><b>To trigger, type:</b> <code>allporncomic.com</code><br>
|
||||
<b>Text file name:</b> <code>allporncomic.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>nhentai.net</code><br>
|
||||
<b>Text file name:</b> <code>nhentai.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>fap-nation.com</code> or <code>fap-nation.org</code><br>
|
||||
<b>Text file name:</b> <code>fap-nation.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>saint2.su</code><br>
|
||||
<b>Text file name:</b> <code>saint2.su.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>hentai2read.com</code><br>
|
||||
<b>Text file name:</b> <code>hentai2read.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>rule34video.com</code><br>
|
||||
<b>Text file name:</b> <code>rule34video.txt</code></li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Special Modes: Text & Links",
|
||||
"""
|
||||
<p>These two modes completely change the downloader's function from downloading files to extracting information.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>🔗 Only Links Mode</h3>
|
||||
<p>When you select this, the app <b>stops downloading files</b>. Instead, it scans the post's description for any external URLs (like Mega, Google Drive, Dropbox, etc.) and lists them in the main log.</p>
|
||||
<p>This mode also reveals a new set of tools above the log:</p>
|
||||
<ul>
|
||||
<li><b>Search Bar:</b> Lets you filter the extracted links by keyword (e.g., "mega", "part 1").</li>
|
||||
<li><b>Export Links Button:</b> Opens a dialog to save all the found links to a <code>.txt</code> file.</li>
|
||||
<li><b>Download Button:</b> Opens a new dialog that lets you selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>📄 More (Text Export Mode)</h3>
|
||||
<p>This mode downloads the <b>text content</b> from posts instead of the files. When you select it, a dialog appears asking for more details:</p>
|
||||
<ul>
|
||||
<li><b>Scope:</b>
|
||||
<ul>
|
||||
<li><b>Description/Content:</b> Saves the text from the post's main body.</li>
|
||||
<li><b>Comments:</b> Fetches and saves all the comments from the post.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><b>Export as:</b> You can choose to save the text as a <b>PDF</b>, <b>DOCX</b>, or <b>TXT</b> file.</li>
|
||||
<li><b>Single PDF:</b> (Only available for PDF format) This powerful option stops the app from saving individual PDF files. Instead, it collects the text from <i>all</i> matching posts, sorts them by date, and compiles them into <b>one single, large PDF file</b> at the end of the download session.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Special Commands",
|
||||
"""
|
||||
<p>You can add special commands to the <b>"Filter by Character(s)"</b> input field to change download behavior for a single task. Commands are keywords wrapped in square brackets <code>[]</code>.</p>
|
||||
<p><b>Example:</b> <code>Tifa, (Cloud, Zack) [ao] [sfp-10]</code></p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Filter Commands (in "Filter by Character(s)" input)</h3>
|
||||
<ul>
|
||||
<li><b><code>[ao]</code> (Archive Only Priority)</b><br>
|
||||
This command prioritizes archives.
|
||||
<ul>
|
||||
<li>If a post contains <b>only images/videos</b>, it will download them normally.</li>
|
||||
<li>If a post contains <b>both archives AND images/videos</b>, this command tells the app to <b>only download the archives</b> and skip the other files for that post.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[sfp-N]</code> (Subfolder Per Post Threshold)</b><br>
|
||||
This is an override for when "Subfolder per Post" is <b>OFF</b> (and "Separate Folders by Known.txt" is <b>ON</b>).<br>
|
||||
For example, if you set <code>[sfp-10]</code>:
|
||||
<ul>
|
||||
<li>Posts with <b>less than 10 files</b> will download normally into the main folder (e.g., <code>/ArtistName/</code>).</li>
|
||||
<li>When a post with <b>10 or more files</b> is found, this command will <b>force a subfolder to be created for that one post</b> (e.g., <code>/ArtistName/Comic_Title/</code>) to keep its files grouped together.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[unknown]</code> (Handle Unknown)</b><br>
|
||||
Changes how sorting works when "Separate Folders by Known.txt" is on. If a post title doesn't match any name in your <code>Known.txt</code> list, this command will create a folder using the post's title instead of a generic fallback folder.
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[.domain]</code> (Domain Override)</b><br>
|
||||
An advanced command. For example, <code>[.st]</code> forces the app to download from <code>coomer.st</code>, and <code>[.cr]</code> forces it to download from <code>kemono.cr</code>. This can be useful if one domain is blocked or slow.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Skip Command (in "Skip with Words" input)</h3>
|
||||
<p>This command is different and goes into the <b>"Skip with Words"</b> input field, along with any other skip words.</p>
|
||||
<ul>
|
||||
<li><b><code>[N]</code> (Skip File by Size)</b><br>
|
||||
This command skips any file that is <b>smaller</b> than <code>N</code> megabytes (MB).<br>
|
||||
<b>Example:</b> Entering <code>WIP, sketch, [200]</code> into the "Skip with Words" input will skip files with "WIP" or "sketch" in their name, AND it will also skip any file smaller than 200MB.
|
||||
</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Cloud Storage & Direct Links",
|
||||
"""
|
||||
<p>The downloader has built-in support for popular cloud storage and direct-link sites. You can use this in two main ways.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Method 1: Direct URL Download</h3>
|
||||
<p>You can paste a direct link from these services into the main URL bar and hit "Start Download" just like a Kemono link.</p>
|
||||
<ul>
|
||||
<li><b>Pixeldrain:</b> Supports single files (<code>/u/...</code>), albums (<code>/l/...</code>), and folders (<code>/d/...</code>).</li>
|
||||
<li><b>Mega.nz:</b> Supports both single file links (<code>/file/...</code>) and folder links (<code>/folder/...</code>).</li>
|
||||
<li><b>Gofile.io:</b> Supports folder links (<code>/d/...</code>).</li>
|
||||
<li><b>Google Drive:</b> Supports shared folder links.</li>
|
||||
<li><b>Dropbox:</b> Supports shared <code>.zip</code> file links. It will automatically download, extract, and delete the <code>.zip</code> file.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Method 2: "Only Links" Mode Downloader</h3>
|
||||
<p>This is a two-step process for handling posts that have many cloud links in their description.</p>
|
||||
<ol>
|
||||
<li><b>Step 1: Extract Links</b><br>
|
||||
Select the <b>"🔗 Only Links"</b> radio button and run a download on a creator or post page. The app will scan all posts and list the external links (Mega, GDrive, etc.) it finds in the log.
|
||||
</li>
|
||||
<br>
|
||||
<li><b>Step 2: Download Links</b><br>
|
||||
After extraction, a <b>"Download"</b> button (next to "Export Links") will become active. This opens a new window where you can selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Note: SimpCity Integration</h3>
|
||||
<p>SimpCity support relies heavily on this feature. When you download from a SimpCity thread, the app <b>automatically</b> scans the page for links to services like <b>Pixeldrain, Bunkr, Saint2, Mega, and Gofile</b> and then downloads them just as if you had put in those links directly. You can control which of these services are downloaded from the checkboxes in the "SimpCity Settings" section of the main window.</p>
|
||||
"""),
|
||||
|
||||
("Creator Selection & Updates",
|
||||
"""
|
||||
<p>Clicking the <b>🎨 button</b> (next to the URL bar) opens the <b>Creator Selection</b> dialog. This is your control for managing creators you've already downloaded from.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Main List & Searching</h3>
|
||||
<p>The main list shows all creators from your <code>creators.json</code> file. You can:</p>
|
||||
<ul>
|
||||
<li><b>Search:</b> The top search bar filters your creators by name, service, or even a direct URL.</li>
|
||||
<li><b>Select:</b> Check the boxes next to creators to select them for an action.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Action Buttons</h3>
|
||||
|
||||
<p><b>Check for Updates</b></p>
|
||||
<p>This button opens a new window, "Check for Updates," which lists all your <b>Creator Profiles</b> (the <code>.json</code> files saved in your <code>appdata/creator_profiles</code> folder). These profiles are created automatically when you download a full creator page.</p>
|
||||
<p>From this dialog, you can check multiple creators at once. The app will scan all of them and then show a final "Start Download" button on the main window to download <i>only</i> the new posts, using the same settings you used for each creator last time.</p>
|
||||
|
||||
<p><b>Add Selected</b></p>
|
||||
<p>This is the simplest action. It takes all the creators you've checked, puts their names in the main URL bar, and closes the dialog. This is a quick way to add multiple creators to the download queue for a download.</p>
|
||||
|
||||
<p><b>Fetch Posts</b></p>
|
||||
<p>This is a powerful tool for finding specific posts. When you click it:</p>
|
||||
<ol>
|
||||
<li>The dialog expands, and a new panel appears on the right.</li>
|
||||
<li>The app fetches <i>every single post</i> from all the creators you selected. This may take time.</li>
|
||||
<li>The right panel fills with a list of all posts, grouped by creator.</li>
|
||||
<li>You can now search this list and check the boxes next to the <i>individual posts</i> you want.</li>
|
||||
<li>Clicking <b>"Add Selected Posts to Queue"</b> adds only those specific posts to the download queue.</li>
|
||||
</ol>
|
||||
"""),
|
||||
|
||||
("⭐ Favorite Mode",
|
||||
"""
|
||||
<p>This mode is a powerful feature for downloading directly from your personal <b>Kemono</b> and <b>Coomer</b> favorites lists. It requires you to be logged in on your browser and to provide your cookies to the app.</p>
|
||||
|
||||
<p><b style='color: #f0ad4e;'>Important:</b> You <b>must</b> check the <b>"Use Cookie"</b> box and provide a valid cookie for this mode to work. If cookies are missing or invalid, the app will show you a help dialog.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>How to Use Favorite Mode</h3>
|
||||
<ol>
|
||||
<li>Check the <b>"⭐ Favorite Mode"</b> checkbox on the main window. This will lock the URL bar and show two new buttons.</li>
|
||||
<li>Click either <b>"🖼️ Favorite Artists"</b> or <b>"📄 Favorite Posts"</b>.</li>
|
||||
<li>A new dialog will open and begin fetching all your favorites from both Kemono and Coomer at the same time.</li>
|
||||
<li>Once loaded, you can search, filter, and select the artists or posts you want to download.</li>
|
||||
<li>Click "Download Selected" to add them to the main download queue and begin processing.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Favorite Artists</h3>
|
||||
<p>The <b>"Favorite Artists"</b> dialog will load your list of followed creators. When you download from here, the app treats it as a full creator download, just as if you had pasted in that artist's URL.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Favorite Posts</h3>
|
||||
<p>The <b>"Favorite Posts"</b> dialog loads a list of every individual post you have favorited. This dialog has some extra features:</p>
|
||||
<ul>
|
||||
<li><b>Creator Name Resolution:</b> It attempts to match the post's creator ID with the names in your <code>creators.json</code> file to show you a recognizable name.</li>
|
||||
<li><b>Known.txt Matching:</b> It highlights posts by showing <code>[Known - Tifa]</code> in the title if the post title matches an entry in your <code>Known.txt</code> list, helping you find specific content.</li>
|
||||
<li><b>Grouping:</b> Posts are automatically grouped by creator to keep the list organized.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Download Scope (Artist Folders)</h3>
|
||||
<p>In Favorite Mode, the <b>"Scope: [Location]"</b> button becomes very important. It controls <i>where</i> your favorited items are saved:</p>
|
||||
<ul>
|
||||
<li><b>Scope: Selected Location (Default):</b> Downloads all selected items directly into the main "Download Location" folder you have set.</li>
|
||||
<li><b>Scope: Artist Folders:</b> This automatically creates a new subfolder for each artist inside your main "Download Location" (e.g., <code>/Downloads/ArtistName/</code>). This is the best way to keep your favorites organized.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("File & Download Options",
|
||||
"""
|
||||
<p>These checkboxes give you fine-grained control over which files are downloaded and how they are processed.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>File Type & Content</h3>
|
||||
<ul>
|
||||
<li><b>Skip .zip:</b> A simple toggle. When checked, the downloader will skip all <code>.zip</code> and <code>.rar</code> archive files it finds.</li>
|
||||
<br>
|
||||
<li><b>Scan Content for Images:</b> This is a powerful feature for posts where images are embedded in the description (<code><img></code> tags) but not listed as attachments. When checked, the app will scan the post's HTML content and try to find and download these embedded images.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Image Processing</h3>
|
||||
<ul>
|
||||
<li><b>Download Thumbnails Only:</b> Saves bandwidth and time by downloading the small preview/thumbnail version of an image instead of the full-resolution file.</li>
|
||||
<br>
|
||||
<li><b>Compress to WebP:</b> If an image is over 1.5MB, this option will automatically convert it to the <code>.webp</code> format during the download, which significantly reduces file size while maintaining high quality.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Duplicate Handling</h3>
|
||||
<ul>
|
||||
<li><b>Keep Duplicates:</b> By default, the app checks the <i>content</i> (hash) of a file and will not re-download a file it already has. Checking this box opens a dialog with more options:
|
||||
<ul>
|
||||
<li><b>Hash (Default):</b> The standard behavior.</li>
|
||||
<li><b>Keep Everything:</b> Disables all duplicate checks and downloads every file from the API, even if you already have it.</li>
|
||||
<li><b>Limit:</b> Lets you set a limit (e.g., 2) to how many times a file with the same content can be downloaded.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Utility & Advanced Options",
|
||||
"""
|
||||
<p>These features provide advanced control over your downloads, sessions, and application settings.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Use Cookie</h3>
|
||||
<p>This is essential for downloading from sites that require a login (like <b>SimpCity</b> or accessing your <b>favorites</b> on Kemono/Coomer). You can either:</p>
|
||||
<ul>
|
||||
<li><b>Paste a cookie string:</b> Copy the "cookie" value from your browser's developer tools and paste it into the text field.</li>
|
||||
<li><b>Use a file:</b> Click the "Browse" button to select a <code>cookies.txt</code> file exported from your browser.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Page Range</h3>
|
||||
<p>When downloading from a creator's main page (not a single post), these "Start" and "End" fields let you limit the download. For example, entering <code>Start: 1</code> and <code>End: 5</code> will only download posts from the first five pages.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Multi-part Download</h3>
|
||||
<p>Clicking the <b>"Multi-part: OFF"</b> button opens a dialog to enable high-speed downloads for large files. It will split a large file into multiple parts and download them at the same time. You can choose to apply this to videos, archives, or both, and set the minimum file size to trigger it.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Download History</h3>
|
||||
<p>The <b>"History"</b> button opens a dialog showing two lists:</p>
|
||||
<ul>
|
||||
<li><b>Last 3 Files:</b> The last 3 individual files you successfully downloaded.</li>
|
||||
<li><b>First 3 Posts:</b> The first 3 posts Processed from your *most recent* download session.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Settings (Gear Icon)</h3>
|
||||
<p>The <b>Gear</b> icon ⚙️ opens the main application settings, which is now organized into tabs:</p>
|
||||
<ul>
|
||||
<li><b>Display Tab:</b> Change the app's <b>Theme</b> (Light/Dark), <b>Language</b>, <b>UI Scale</b>, and default <b>Window Size</b>.</li>
|
||||
<li><b>Downloads Tab:</b>
|
||||
<ul>
|
||||
<li>Save your current <b>Download Path</b>, <b>Cookie</b>, and <b>Discord Token</b> for future sessions using the "Save Path + Cookie + Token" button.</li>
|
||||
<li>Set an <b>Action After Download</b> (e.g., Notify, Sleep, Shutdown).</li>
|
||||
<li>Customize the <b>Post Subfolder Format</b> for when the date prefix is used (e.g., <code>YYYY-MM-DD {post}</code>).</li>
|
||||
<li>Toggle <b>"Save Creator.json file"</b> (which enables the "Check for Updates" feature).</li>
|
||||
<li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><b>Updates Tab:</b> Check for and install new application updates.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Reset Button</h3>
|
||||
<p>The <b>"Reset"</b> button (bottom right) is a soft reset. It clears all input fields (except your Download Location), clears the logs, and resets all download options and filters back to their default state. It does <b>not</b> clear your Download History or saved Settings.</p>
|
||||
""")
|
||||
]
|
||||
super().__init__(parent)
|
||||
self.steps_data = steps_data
|
||||
self.parent_app = parent_app
|
||||
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
|
||||
@@ -505,38 +66,7 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
current_theme_style = ""
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
base_style = get_dark_theme(scale)
|
||||
|
||||
list_widget_style = f"""
|
||||
QListWidget {{
|
||||
background-color: #2E2E2E;
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
color: #DCDCDC;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #4A4A4A;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #87CEEB;
|
||||
color: #1E1E1E;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: #3A3A3A;
|
||||
}}
|
||||
|
||||
/* Style for the TourStepWidget content */
|
||||
TourStepWidget QLabel {{
|
||||
color: #DCDCDC;
|
||||
}}
|
||||
TourStepWidget QScrollArea {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
"""
|
||||
current_theme_style = base_style + list_widget_style
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
else:
|
||||
# Basic light theme fallback
|
||||
current_theme_style = f"""
|
||||
@@ -553,50 +83,29 @@ class HelpGuideDialog(QDialog):
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #CACACA; }}
|
||||
QPushButton:pressed {{ background-color: #B0B0B0; }}
|
||||
QListWidget {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #C0C0C0;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
color: #1E1E1E;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #0078D7;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: #F0F0F0;
|
||||
}}
|
||||
TourStepWidget QLabel {{
|
||||
color: #1E1E1E;
|
||||
}}
|
||||
TourStepWidget h3 {{
|
||||
color: #005A9E;
|
||||
}}
|
||||
"""
|
||||
|
||||
self.setStyleSheet(current_theme_style)
|
||||
self._init_ui()
|
||||
|
||||
if self.parent_app:
|
||||
self.move(self.parent_app.geometry().center() - self.rect().center())
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _init_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(15, 15, 15, 15)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("Kemono Downloader - Feature Guide")
|
||||
title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
title_font_size = int(16 * scale)
|
||||
# Use a consistent color for the main title
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB;")
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;")
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title_label)
|
||||
|
||||
@@ -606,14 +115,34 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
self.nav_list = QListWidget()
|
||||
self.nav_list.setFixedWidth(int(220 * scale))
|
||||
# Styles are now set in the __init__ method
|
||||
self.nav_list.setStyleSheet(f"""
|
||||
QListWidget {{
|
||||
background-color: #2E2E2E;
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #4A4A4A;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #87CEEB;
|
||||
color: #2E2E2E;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
content_layout.addWidget(self.nav_list)
|
||||
|
||||
self.stacked_widget = QStackedWidget()
|
||||
content_layout.addWidget(self.stacked_widget)
|
||||
|
||||
for title, content in self.steps_data:
|
||||
for title_key, content_key in self.steps_data:
|
||||
title = self._tr(title_key, title_key)
|
||||
content = self._tr(content_key, f"Content for {content_key} not found.")
|
||||
|
||||
self.nav_list.addItem(title)
|
||||
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
@@ -642,19 +171,13 @@ class HelpGuideDialog(QDialog):
|
||||
icon_dim = int(24 * scale)
|
||||
icon_size = QSize(icon_dim, icon_dim)
|
||||
|
||||
tooltip_map = {
|
||||
"help_guide_github_tooltip": "Visit the project on GitHub",
|
||||
"help_guide_instagram_tooltip": "Follow the developer on Instagram",
|
||||
"help_guide_discord_tooltip": "Join the official Discord server"
|
||||
}
|
||||
|
||||
for button, tooltip_key, url in [
|
||||
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi63771/Kemono-Downloader"),
|
||||
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
|
||||
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
|
||||
]:
|
||||
button.setIconSize(icon_size)
|
||||
button.setToolTip(tooltip_map.get(tooltip_key, ""))
|
||||
button.setToolTip(self._tr(tooltip_key))
|
||||
button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
|
||||
button.setStyleSheet("background-color: transparent; border: none;")
|
||||
button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
|
||||
@@ -662,7 +185,7 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
footer_layout.addStretch(1)
|
||||
|
||||
self.finish_button = QPushButton("Finish")
|
||||
self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
|
||||
self.finish_button.clicked.connect(self.accept)
|
||||
footer_layout.addWidget(self.finish_button)
|
||||
|
||||
|
||||
@@ -11,16 +11,17 @@ class MoreOptionsDialog(QDialog):
|
||||
SCOPE_CONTENT = "content"
|
||||
SCOPE_COMMENTS = "comments"
|
||||
|
||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False, add_info_checked=False):
|
||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_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")
|
||||
@@ -49,20 +50,14 @@ class MoreOptionsDialog(QDialog):
|
||||
export_layout.addStretch()
|
||||
layout.addLayout(export_layout)
|
||||
|
||||
# --- Single PDF Checkbox ---
|
||||
# --- UPDATED: 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)
|
||||
|
||||
# --- 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.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
|
||||
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
@@ -70,18 +65,12 @@ class MoreOptionsDialog(QDialog):
|
||||
layout.addWidget(self.button_box)
|
||||
self.setLayout(layout)
|
||||
self._apply_theme()
|
||||
|
||||
def update_checkbox_states(self, text):
|
||||
"""Enable PDF-specific checkboxes only if the format is PDF."""
|
||||
def update_single_pdf_checkbox_state(self, text):
|
||||
"""Enable the Single PDF checkbox 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():
|
||||
@@ -95,14 +84,13 @@ 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 hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
# Get the scale factor from the parent app
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
# Call the imported function with the correct scale
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
# Explicitly set a blank stylesheet for light mode
|
||||
self.setStyleSheet("")
|
||||
|
||||
@@ -4,22 +4,24 @@ 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)
|
||||
self.set_font(self.font_family_main, '', 8)
|
||||
if self.font_family:
|
||||
self.set_font(self.font_family, '', 8)
|
||||
else:
|
||||
self.set_font('Arial', '', 8)
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
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
|
||||
|
||||
@@ -29,169 +31,12 @@ def strip_html_tags(text):
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
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):
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
||||
"""
|
||||
Creates a PDF for a single post.
|
||||
Supports optional metadata page and appending comments.
|
||||
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
|
||||
"""
|
||||
if not FPDF_AVAILABLE:
|
||||
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.")
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||
return False
|
||||
|
||||
if not posts_data:
|
||||
@@ -199,21 +44,34 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, add_i
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
font_family = _setup_pdf_fonts(pdf, font_path, logger)
|
||||
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()
|
||||
|
||||
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for i, post in enumerate(posts_data):
|
||||
if add_info_page:
|
||||
add_metadata_page(pdf, post, font_family)
|
||||
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
|
||||
else:
|
||||
if i > 0:
|
||||
# This ensures every post after the first gets its own page.
|
||||
pdf.add_page()
|
||||
|
||||
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)
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
if 'comments' in post and post['comments']:
|
||||
comments_list = post['comments']
|
||||
@@ -222,17 +80,17 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, add_i
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
|
||||
pdf.set_font(font_family, '', 10)
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
if user is not None:
|
||||
pdf.set_font(font_family, 'B', 10)
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, str(user))
|
||||
|
||||
pdf.set_font(font_family, '', 10)
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(font_family, '', 11)
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(w=0, h=7, txt=body)
|
||||
|
||||
if comment_index < len(comments_list) - 1:
|
||||
@@ -240,7 +98,7 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, add_i
|
||||
pdf.cell(w=0, h=0, border='T')
|
||||
pdf.ln(3)
|
||||
elif 'content' in post:
|
||||
pdf.set_font(font_family, '', 12)
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
@@ -249,4 +107,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, add_i
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@@ -21,16 +21,11 @@ class UpdateCheckDialog(QDialog):
|
||||
and allows the user to select multiple to check for updates.
|
||||
"""
|
||||
|
||||
def __init__(self, user_data_path, parent_app_ref, parent=None):
|
||||
def __init__(self, app_base_dir, parent_app_ref, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent_app_ref
|
||||
self.user_data_path = user_data_path
|
||||
self.app_base_dir = app_base_dir
|
||||
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()
|
||||
@@ -61,16 +56,8 @@ 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
|
||||
@@ -110,11 +97,10 @@ 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."""
|
||||
appdata_dir = self.user_data_path
|
||||
appdata_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
|
||||
if not os.path.isdir(profiles_dir):
|
||||
@@ -158,44 +144,16 @@ 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."""
|
||||
@@ -218,9 +176,4 @@ 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()
|
||||
return self.selected_profiles_list
|
||||
@@ -163,8 +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":
|
||||
try:
|
||||
@@ -340,7 +339,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 v7.8.0")
|
||||
self.setWindowTitle("Kemono Downloader v7.6.0")
|
||||
setup_ui(self)
|
||||
self._connect_signals()
|
||||
if hasattr(self, 'character_input'):
|
||||
@@ -657,7 +656,6 @@ 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
|
||||
|
||||
@@ -938,7 +936,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
|
||||
|
||||
@@ -2940,59 +2938,33 @@ class DownloaderApp (QWidget ):
|
||||
return True
|
||||
|
||||
def _handle_more_options_toggled(self, button, checked):
|
||||
"""
|
||||
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
|
||||
"""Shows the MoreOptionsDialog when the 'More' radio button is 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,
|
||||
add_info_checked=self.add_info_in_pdf_setting # <--- Pass current setting
|
||||
single_pdf_checked=self.single_pdf_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"
|
||||
|
||||
# Construct a descriptive label (e.g., "Description (PDF [Single+Info])")
|
||||
format_extras = []
|
||||
format_display = f" ({self.text_export_format.upper()})"
|
||||
if self.single_pdf_setting:
|
||||
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})"
|
||||
|
||||
format_display = " (Single PDF)"
|
||||
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:
|
||||
@@ -3003,39 +2975,23 @@ class DownloaderApp (QWidget ):
|
||||
if self.single_pdf_setting:
|
||||
self.use_subfolder_per_post_checkbox.setChecked(False)
|
||||
|
||||
# --- Logging ---
|
||||
self.log_signal.emit(f"ℹ️ 'More' filter set: {scope_text}, Format: {self.text_export_format.upper()}")
|
||||
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'}")
|
||||
if is_any_pdf_mode:
|
||||
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.")
|
||||
|
||||
self.log_signal.emit("ℹ️ Multithreading automatically 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')
|
||||
self.radio_all.setChecked(True)
|
||||
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() # Re-check manga logic
|
||||
|
||||
self._update_multithreading_for_date_mode()
|
||||
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 ()
|
||||
@@ -3178,66 +3134,55 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Renaming Mode is active for a creator feed).")
|
||||
|
||||
def _toggle_manga_filename_style(self):
|
||||
def _toggle_manga_filename_style (self ):
|
||||
url_text = self.link_input.text().strip() if self.link_input else ""
|
||||
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
|
||||
|
||||
_, _, post_id = extract_post_info(url_text)
|
||||
is_single_post = bool(post_id)
|
||||
|
||||
current_style = self.manga_filename_style
|
||||
new_style = ""
|
||||
|
||||
|
||||
if is_single_post:
|
||||
# ... (Cycle logic for single posts) ...
|
||||
if current_style == STYLE_POST_TITLE:
|
||||
# Cycle through a limited set of styles suitable for single posts
|
||||
if current_style == STYLE_POST_TITLE:
|
||||
new_style = STYLE_DATE_POST_TITLE
|
||||
elif current_style == STYLE_DATE_POST_TITLE:
|
||||
elif current_style == STYLE_DATE_POST_TITLE:
|
||||
new_style = STYLE_ORIGINAL_NAME
|
||||
elif current_style == STYLE_ORIGINAL_NAME:
|
||||
elif current_style == STYLE_ORIGINAL_NAME:
|
||||
new_style = STYLE_POST_ID
|
||||
elif current_style == STYLE_POST_ID:
|
||||
elif current_style == STYLE_POST_ID:
|
||||
new_style = STYLE_CUSTOM
|
||||
elif current_style == STYLE_CUSTOM:
|
||||
elif current_style == STYLE_CUSTOM:
|
||||
new_style = STYLE_POST_TITLE
|
||||
else:
|
||||
else: # Fallback for any other style
|
||||
new_style = STYLE_POST_TITLE
|
||||
else:
|
||||
# ... (Cycle logic for creators) ...
|
||||
if current_style == STYLE_POST_TITLE:
|
||||
new_style = STYLE_ORIGINAL_NAME
|
||||
elif current_style == STYLE_ORIGINAL_NAME:
|
||||
new_style = STYLE_DATE_POST_TITLE
|
||||
elif current_style == STYLE_DATE_POST_TITLE:
|
||||
new_style = STYLE_POST_TITLE_GLOBAL_NUMBERING
|
||||
elif current_style == STYLE_POST_TITLE_GLOBAL_NUMBERING:
|
||||
new_style = STYLE_DATE_BASED
|
||||
elif current_style == STYLE_DATE_BASED:
|
||||
new_style = STYLE_POST_ID
|
||||
elif current_style == STYLE_POST_ID:
|
||||
new_style = STYLE_CUSTOM
|
||||
elif current_style == STYLE_CUSTOM:
|
||||
new_style = STYLE_POST_TITLE
|
||||
else:
|
||||
new_style = STYLE_POST_TITLE
|
||||
# Original cycling logic for creator feeds
|
||||
if current_style ==STYLE_POST_TITLE :
|
||||
new_style =STYLE_ORIGINAL_NAME
|
||||
elif current_style ==STYLE_ORIGINAL_NAME :
|
||||
new_style =STYLE_DATE_POST_TITLE
|
||||
elif current_style ==STYLE_DATE_POST_TITLE :
|
||||
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
|
||||
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
|
||||
new_style =STYLE_DATE_BASED
|
||||
elif current_style ==STYLE_DATE_BASED :
|
||||
new_style =STYLE_POST_ID
|
||||
elif current_style ==STYLE_POST_ID:
|
||||
new_style =STYLE_CUSTOM # <-- CHANGE THIS
|
||||
elif current_style == STYLE_CUSTOM: # <-- ADD THIS
|
||||
new_style = STYLE_POST_TITLE # <-- ADD THIS
|
||||
else :
|
||||
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
|
||||
new_style =STYLE_POST_TITLE
|
||||
|
||||
self .manga_filename_style =new_style
|
||||
self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
|
||||
self .settings .sync ()
|
||||
self ._update_manga_filename_style_button_text ()
|
||||
self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
|
||||
self .log_signal .emit (f"ℹ️ Manga filename style changed to: '{self .manga_filename_style }'")
|
||||
|
||||
self.manga_filename_style = new_style
|
||||
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
|
||||
self.settings.sync()
|
||||
self._update_manga_filename_style_button_text()
|
||||
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
|
||||
|
||||
def _handle_favorite_mode_toggle (self ,checked ):
|
||||
if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
|
||||
return
|
||||
@@ -3293,6 +3238,7 @@ 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)
|
||||
@@ -3301,11 +3247,13 @@ 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
|
||||
@@ -3315,7 +3263,8 @@ class DownloaderApp (QWidget ):
|
||||
if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue):
|
||||
is_single_post = True
|
||||
|
||||
can_enable_manga_checkbox = (is_favorite_mode_on or not is_discord_url)
|
||||
# --- MODIFIED: Added check for is_discord_url ---
|
||||
can_enable_manga_checkbox = ((is_creator_feed or is_single_post) or is_favorite_mode_on) and not is_discord_url
|
||||
if self .manga_mode_checkbox :
|
||||
self .manga_mode_checkbox .setEnabled (can_enable_manga_checkbox)
|
||||
if not can_enable_manga_checkbox and self .manga_mode_checkbox .isChecked ():
|
||||
@@ -3332,6 +3281,7 @@ 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 ()
|
||||
|
||||
@@ -3369,20 +3319,7 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def _show_custom_rename_dialog(self):
|
||||
"""Shows the dialog to edit the custom manga filename format."""
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
dialog = CustomFilenameDialog(self.custom_manga_filename_format, self.manga_custom_date_format, self)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.custom_manga_filename_format = dialog.get_format_string()
|
||||
self.manga_custom_date_format = dialog.get_date_format_string()
|
||||
@@ -3392,6 +3329,7 @@ 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")
|
||||
@@ -3508,7 +3446,7 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def _update_contextual_ui_elements(self, text=""):
|
||||
"""Shows or hides UI elements based on the URL, like the Discord scope button."""
|
||||
|
||||
|
||||
if 'allporncomic.com' in text.lower() and not hasattr(self, 'allcomic_warning_shown'):
|
||||
self.allcomic_warning_shown = False
|
||||
if 'allporncomic.com' in text.lower() and not self.allcomic_warning_shown:
|
||||
@@ -3531,53 +3469,13 @@ 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)
|
||||
|
||||
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_any_discord_url = (service == 'discord')
|
||||
is_official_discord_url = 'discord.com' in url_text and is_any_discord_url
|
||||
|
||||
if is_official_discord_url:
|
||||
@@ -3592,6 +3490,15 @@ 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)
|
||||
@@ -3627,12 +3534,7 @@ class DownloaderApp (QWidget ):
|
||||
return get_theme_stylesheet(actual_scale)
|
||||
|
||||
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
|
||||
|
||||
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, MAX_FILE_THREADS_PER_POST_OR_WORKER
|
||||
|
||||
from ..utils.file_utils import clean_folder_name, KNOWN_NAMES
|
||||
from ..config.constants import FOLDER_NAME_STOP_WORDS
|
||||
|
||||
|
||||
if not is_restore and not is_continuation:
|
||||
if self.main_log_output: self.main_log_output.clear()
|
||||
if self.external_log_output: self.external_log_output.clear()
|
||||
@@ -3646,29 +3548,7 @@ 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+)?/?'
|
||||
},
|
||||
|
||||
batch_handlers = {
|
||||
'allporncomic.com': {
|
||||
'name': 'AllPornComic',
|
||||
'txt_file': 'allporncomic.txt',
|
||||
@@ -3742,75 +3622,21 @@ 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} 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
|
||||
'name': f"{name} link from batch",
|
||||
'type': 'post'
|
||||
})
|
||||
|
||||
|
||||
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
|
||||
@@ -3824,6 +3650,7 @@ 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()
|
||||
@@ -4061,6 +3888,30 @@ class DownloaderApp (QWidget ):
|
||||
num_threads_from_gui = MAX_THREADS
|
||||
self.thread_count_input.setText(str(MAX_THREADS))
|
||||
self.log_signal.emit(f"⚠️ User attempted {num_threads_from_gui} threads, capped to {MAX_THREADS}.")
|
||||
if SOFT_WARNING_THREAD_THRESHOLD < num_threads_from_gui <= MAX_THREADS:
|
||||
soft_warning_msg_box = QMessageBox(self)
|
||||
soft_warning_msg_box.setIcon(QMessageBox.Question)
|
||||
soft_warning_msg_box.setWindowTitle("Thread Count Advisory")
|
||||
soft_warning_msg_box.setText(
|
||||
f"You've set the thread count to {num_threads_from_gui}.\n\n"
|
||||
"While this is within the allowed limit, using a high number of threads (typically above 40-50) can sometimes lead to:\n"
|
||||
" - Increased errors or failed file downloads.\n"
|
||||
" - Connection issues with the server.\n"
|
||||
" - Higher system resource usage.\n\n"
|
||||
"For most users and connections, 10-30 threads provide a good balance.\n\n"
|
||||
f"Do you want to proceed with {num_threads_from_gui} threads, or would you like to change the value?"
|
||||
)
|
||||
proceed_button = soft_warning_msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
|
||||
change_button = soft_warning_msg_box.addButton("Change Thread Value", QMessageBox.RejectRole)
|
||||
soft_warning_msg_box.setDefaultButton(proceed_button)
|
||||
soft_warning_msg_box.setEscapeButton(change_button)
|
||||
soft_warning_msg_box.exec_()
|
||||
|
||||
if soft_warning_msg_box.clickedButton() == change_button:
|
||||
self.log_signal.emit(f"ℹ️ User opted to change thread count from {num_threads_from_gui} after advisory.")
|
||||
self.thread_count_input.setFocus()
|
||||
self.thread_count_input.selectAll()
|
||||
return False
|
||||
|
||||
raw_skip_words_text = self.skip_words_input.text().strip()
|
||||
skip_words_parts = [part.strip() for part in raw_skip_words_text.split(',') if part.strip()]
|
||||
@@ -4167,6 +4018,26 @@ class DownloaderApp (QWidget ):
|
||||
if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.")
|
||||
if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.")
|
||||
|
||||
if manga_mode and start_page and end_page:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setWindowTitle("Renaming Mode & Page Range Warning")
|
||||
msg_box.setText(
|
||||
"You have enabled <b>Renaming Mode</b> with a sequential naming style (<b>Date Based</b> or <b>Title + G.Num</b>) and also specified a <b>Page Range</b>.\n\n"
|
||||
"These modes rely on processing all posts from the beginning to create a correct sequence. "
|
||||
"Using a page range may result in an incomplete or incorrectly ordered download.\n\n"
|
||||
"It is recommended to use these styles without a page range.\n\n"
|
||||
"Do you want to proceed anyway?"
|
||||
)
|
||||
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
|
||||
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
|
||||
msg_box.setDefaultButton(proceed_button)
|
||||
msg_box.setEscapeButton(cancel_button)
|
||||
msg_box.exec_()
|
||||
|
||||
if msg_box.clickedButton() == cancel_button:
|
||||
self.log_signal.emit("❌ Download cancelled by user due to Renaming Mode & Page Range warning.")
|
||||
return False
|
||||
except ValueError as e:
|
||||
QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}")
|
||||
return False
|
||||
@@ -4495,8 +4366,7 @@ 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,
|
||||
'add_info_in_pdf': self.add_info_in_pdf_setting,
|
||||
'handle_unknown_mode': handle_unknown_command
|
||||
}
|
||||
|
||||
args_template['override_output_dir'] = override_output_dir
|
||||
@@ -4909,7 +4779,6 @@ 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.
|
||||
@@ -4952,7 +4821,6 @@ 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:
|
||||
@@ -4961,33 +4829,18 @@ 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()
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
# 4. Manually parse values from the constructed args
|
||||
|
||||
# Set post-specific data
|
||||
@@ -5128,7 +4981,6 @@ 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"
|
||||
@@ -5328,13 +5180,7 @@ 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,
|
||||
add_info_page=self.add_info_in_pdf_setting, # Pass the flag here
|
||||
logger=self.log_signal.emit
|
||||
)
|
||||
create_single_pdf_from_content(sorted_content, filepath, font_path, logger=self.log_signal.emit)
|
||||
self.log_signal.emit("="*40)
|
||||
|
||||
def _add_to_history_candidates(self, history_data):
|
||||
@@ -5732,10 +5578,6 @@ 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
|
||||
@@ -5757,21 +5599,14 @@ 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
|
||||
self.download_thread = None # This is the crucial line
|
||||
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
|
||||
@@ -5826,6 +5661,7 @@ 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)
|
||||
@@ -5851,7 +5687,6 @@ 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?",
|
||||
@@ -5860,11 +5695,7 @@ 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:
|
||||
@@ -5875,9 +5706,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)
|
||||
@@ -5892,6 +5723,7 @@ 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()
|
||||
|
||||
@@ -5899,12 +5731,8 @@ 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()
|
||||
# ---------------------------------------------
|
||||
self.finish_lock.release()
|
||||
|
||||
def _execute_post_download_action(self):
|
||||
"""Checks the settings and performs the chosen action after downloads complete."""
|
||||
@@ -6060,7 +5888,6 @@ 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 (),
|
||||
@@ -6084,11 +5911,13 @@ class DownloaderApp (QWidget ):
|
||||
'target_post_id_from_initial_url':None ,
|
||||
'custom_folder_name':None ,
|
||||
'num_file_threads':1 ,
|
||||
'add_info_in_pdf': self.add_info_in_pdf_setting,
|
||||
|
||||
# --- START: ADDED COOKIE FIX ---
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'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 ,
|
||||
}
|
||||
@@ -6788,32 +6617,16 @@ class DownloaderApp (QWidget ):
|
||||
self._tr("restore_pending_message_creator_selection",
|
||||
"Please 'Restore Download' or 'Discard Session' before selecting new creators."))
|
||||
return
|
||||
dialog = EmptyPopupDialog(self.user_data_path, self)
|
||||
dialog = EmptyPopupDialog(self.app_base_dir, self)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
|
||||
# --- START OF MODIFICATION ---
|
||||
# --- NEW BATCH UPDATE LOGIC ---
|
||||
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)
|
||||
# --- END OF MODIFICATION ---
|
||||
|
||||
|
||||
# --- Original logic for adding creators to queue ---
|
||||
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
|
||||
self.active_update_profile = None # Ensure single update mode is off
|
||||
self.favorite_download_queue.clear()
|
||||
@@ -7006,6 +6819,26 @@ class DownloaderApp (QWidget ):
|
||||
char_filter_is_empty = not self.character_input.text().strip()
|
||||
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
|
||||
|
||||
if manga_mode_is_checked and char_filter_is_empty and not extract_links_only:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setWindowTitle("Renaming Mode Filter Warning")
|
||||
msg_box.setText(
|
||||
"Renaming Mode is enabled, but 'Filter by Character(s)' is empty.\n\n"
|
||||
"This is a one-time warning for this entire batch of downloads.\n\n"
|
||||
"Proceeding without a filter may result in generic filenames and folders.\n\n"
|
||||
"Proceed with the entire batch?"
|
||||
)
|
||||
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
|
||||
cancel_button = msg_box.addButton("Cancel Entire Batch", QMessageBox.RejectRole)
|
||||
msg_box.exec_()
|
||||
if msg_box.clickedButton() == cancel_button:
|
||||
self.log_signal.emit("❌ Entire favorite queue cancelled by user at Renaming Mode warning.")
|
||||
self.favorite_download_queue.clear()
|
||||
self.is_processing_favorites_queue = False
|
||||
self.set_ui_enabled(True)
|
||||
return # Stop processing the queue
|
||||
|
||||
if self ._is_download_active ():
|
||||
self .log_signal .emit ("ℹ️ Waiting for current download to finish before starting next favorite.")
|
||||
return
|
||||
@@ -7060,19 +6893,11 @@ 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,12 +137,6 @@ def extract_post_info(url_string):
|
||||
|
||||
stripped_url = url_string.strip()
|
||||
|
||||
|
||||
# --- DeviantArt Check ---
|
||||
if 'deviantart.com' in stripped_url.lower() or 'fav.me' in stripped_url.lower():
|
||||
# This MUST return 'deviantart' as the first element
|
||||
return 'deviantart', 'placeholder_user', 'placeholder_id' # ----------------------
|
||||
|
||||
# --- Rule34Video Check ---
|
||||
rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
|
||||
if rule34video_match:
|
||||
|
||||
@@ -307,18 +307,14 @@ def setup_ui(main_app):
|
||||
simpcity_settings_label = QLabel("⚙️ SimpCity Download Options:")
|
||||
simpcity_settings_layout.addWidget(simpcity_settings_label)
|
||||
|
||||
# Checkbox row
|
||||
# Checkbox row
|
||||
simpcity_checkboxes_layout = QHBoxLayout()
|
||||
|
||||
main_app.simpcity_dl_images_cb = QCheckBox("Download Images")
|
||||
main_app.simpcity_dl_images_cb.setChecked(True) # Checked by default
|
||||
main_app.simpcity_dl_pixeldrain_cb = QCheckBox("Download Pixeldrain")
|
||||
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
|
||||
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
|
||||
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
|
||||
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile")
|
||||
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_images_cb)
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_pixeldrain_cb)
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
|
||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_cb)
|
||||
@@ -328,6 +324,7 @@ 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:")
|
||||
|
||||
Reference in New Issue
Block a user