mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae9a4bbe2 | ||
|
|
1ad1e53b57 | ||
|
|
77bd428b91 | ||
|
|
4bf57eb752 | ||
|
|
de202961a0 | ||
|
|
e806b6de66 | ||
|
|
cb8dd3b7f3 | ||
|
|
5a8c151c97 | ||
|
|
50ba60a461 | ||
|
|
23521e7060 | ||
|
|
f9c504b936 | ||
|
|
efa0abd0f1 | ||
|
|
7d76d00470 | ||
|
|
1494d3f456 | ||
|
|
675646e763 | ||
|
|
611e892576 | ||
|
|
23fd7f0714 | ||
|
|
cfcd800a49 | ||
|
|
24acec2dc3 | ||
|
|
b5b6c1bc46 |
@@ -68,6 +68,15 @@ DISCORD_TOKEN_KEY = "discord/token"
|
|||||||
|
|
||||||
POST_DOWNLOAD_ACTION_KEY = "postDownloadAction"
|
POST_DOWNLOAD_ACTION_KEY = "postDownloadAction"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Proxy / Network Keys ---
|
||||||
|
PROXY_ENABLED_KEY = "proxy/enabled"
|
||||||
|
PROXY_HOST_KEY = "proxy/host"
|
||||||
|
PROXY_PORT_KEY = "proxy/port"
|
||||||
|
PROXY_USERNAME_KEY = "proxy/username"
|
||||||
|
PROXY_PASSWORD_KEY = "proxy/password"
|
||||||
|
PROXY_TYPE_KEY = "proxy_type"
|
||||||
|
|
||||||
# --- UI Constants and Identifiers ---
|
# --- UI Constants and Identifiers ---
|
||||||
HTML_PREFIX = "<!HTML!>"
|
HTML_PREFIX = "<!HTML!>"
|
||||||
LOG_DISPLAY_LINKS = "links"
|
LOG_DISPLAY_LINKS = "links"
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import queue
|
|||||||
def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func):
|
def run_hentai2read_download(start_url, output_dir, progress_callback, overall_progress_callback, check_pause_func):
|
||||||
"""
|
"""
|
||||||
Orchestrates the download process using a producer-consumer model.
|
Orchestrates the download process using a producer-consumer model.
|
||||||
The main thread scrapes image URLs and puts them in a queue.
|
|
||||||
A pool of worker threads consumes from the queue to download images concurrently.
|
|
||||||
"""
|
"""
|
||||||
scraper = cloudscraper.create_scraper()
|
scraper = cloudscraper.create_scraper()
|
||||||
|
all_failed_files = [] # Track all failures across chapters
|
||||||
|
|
||||||
try:
|
try:
|
||||||
progress_callback(" [Hentai2Read] Scraping series page for all metadata...")
|
progress_callback(" [Hentai2Read] Scraping series page for all metadata...")
|
||||||
@@ -39,8 +38,7 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
|||||||
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
|
final_save_path = os.path.join(output_dir, series_folder, chapter_folder)
|
||||||
os.makedirs(final_save_path, exist_ok=True)
|
os.makedirs(final_save_path, exist_ok=True)
|
||||||
|
|
||||||
# This function now scrapes and downloads simultaneously
|
dl_count, skip_count, chapter_failures = _process_and_download_chapter(
|
||||||
dl_count, skip_count = _process_and_download_chapter(
|
|
||||||
chapter_url=chapter['url'],
|
chapter_url=chapter['url'],
|
||||||
save_path=final_save_path,
|
save_path=final_save_path,
|
||||||
scraper=scraper,
|
scraper=scraper,
|
||||||
@@ -51,9 +49,22 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
|||||||
total_downloaded_count += dl_count
|
total_downloaded_count += dl_count
|
||||||
total_skipped_count += skip_count
|
total_skipped_count += skip_count
|
||||||
|
|
||||||
|
if chapter_failures:
|
||||||
|
all_failed_files.extend(chapter_failures)
|
||||||
|
|
||||||
overall_progress_callback(total_chapters, idx + 1)
|
overall_progress_callback(total_chapters, idx + 1)
|
||||||
if check_pause_func(): break
|
if check_pause_func(): break
|
||||||
|
|
||||||
|
# --- FINAL SUMMARY OF FAILURES ---
|
||||||
|
if all_failed_files:
|
||||||
|
progress_callback("\n" + "="*40)
|
||||||
|
progress_callback(f"❌ SUMMARY: {len(all_failed_files)} files failed permanently after 10 retries:")
|
||||||
|
for fail_msg in all_failed_files:
|
||||||
|
progress_callback(f" • {fail_msg}")
|
||||||
|
progress_callback("="*40 + "\n")
|
||||||
|
else:
|
||||||
|
progress_callback("\n✅ All chapters processed successfully with no permanent failures.")
|
||||||
|
|
||||||
return total_downloaded_count, total_skipped_count
|
return total_downloaded_count, total_skipped_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -63,9 +74,8 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
|||||||
def _get_series_metadata(start_url, progress_callback, scraper):
|
def _get_series_metadata(start_url, progress_callback, scraper):
|
||||||
"""
|
"""
|
||||||
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
|
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
|
||||||
Includes a retry mechanism for the initial connection.
|
|
||||||
"""
|
"""
|
||||||
max_retries = 4 # Total number of attempts (1 initial + 3 retries)
|
max_retries = 4
|
||||||
last_exception = None
|
last_exception = None
|
||||||
soup = None
|
soup = None
|
||||||
|
|
||||||
@@ -77,8 +87,6 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
|||||||
response = scraper.get(start_url, timeout=30)
|
response = scraper.get(start_url, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
# If successful, clear exception and break the loop
|
|
||||||
last_exception = None
|
last_exception = None
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -86,8 +94,8 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
|||||||
last_exception = e
|
last_exception = e
|
||||||
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
|
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s
|
time.sleep(2 * (attempt + 1))
|
||||||
continue # Try again
|
continue
|
||||||
|
|
||||||
if last_exception:
|
if last_exception:
|
||||||
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
|
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
|
||||||
@@ -96,23 +104,36 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
|||||||
try:
|
try:
|
||||||
series_title = "Unknown Series"
|
series_title = "Unknown Series"
|
||||||
artist_name = None
|
artist_name = None
|
||||||
|
|
||||||
|
# 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")
|
metadata_list = soup.select_one("ul.list.list-simple-mini")
|
||||||
|
|
||||||
if metadata_list:
|
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'):
|
for b_tag in metadata_list.find_all('b'):
|
||||||
label = b_tag.get_text(strip=True)
|
label = b_tag.get_text(strip=True)
|
||||||
if label in ("Artist", "Author"):
|
if "Artist" in label or "Author" in label:
|
||||||
a_tag = b_tag.find_next_sibling('a')
|
a_tag = b_tag.find_next_sibling('a')
|
||||||
if a_tag:
|
if a_tag:
|
||||||
artist_name = a_tag.get_text(strip=True)
|
artist_name = a_tag.get_text(strip=True)
|
||||||
if label == "Artist":
|
|
||||||
break
|
break
|
||||||
|
|
||||||
top_level_folder_name = artist_name if artist_name else series_title
|
if not artist_name:
|
||||||
|
artist_link = soup.find('a', href=re.compile(r'/hentai-list/artist/'))
|
||||||
|
if artist_link:
|
||||||
|
artist_name = artist_link.get_text(strip=True)
|
||||||
|
|
||||||
|
if artist_name:
|
||||||
|
top_level_folder_name = f"{artist_name} - {series_title}"
|
||||||
|
else:
|
||||||
|
top_level_folder_name = series_title
|
||||||
|
|
||||||
chapter_links = soup.select("div.media a.pull-left.font-w600")
|
chapter_links = soup.select("div.media a.pull-left.font-w600")
|
||||||
if not chapter_links:
|
if not chapter_links:
|
||||||
@@ -124,7 +145,7 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
|||||||
]
|
]
|
||||||
chapters_to_process.reverse()
|
chapters_to_process.reverse()
|
||||||
|
|
||||||
progress_callback(f" [Hentai2Read] ✅ Found Artist/Series: '{top_level_folder_name}'")
|
progress_callback(f" [Hentai2Read] ✅ Found Metadata: '{top_level_folder_name}'")
|
||||||
progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
|
progress_callback(f" [Hentai2Read] ✅ Found {len(chapters_to_process)} chapters to process.")
|
||||||
|
|
||||||
return top_level_folder_name, chapters_to_process
|
return top_level_folder_name, chapters_to_process
|
||||||
@@ -136,41 +157,68 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
|||||||
def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
|
def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
|
||||||
"""
|
"""
|
||||||
Uses a producer-consumer pattern to download a chapter.
|
Uses a producer-consumer pattern to download a chapter.
|
||||||
The main thread (producer) scrapes URLs one by one.
|
Includes RETRY LOGIC and ACTIVE LOGGING.
|
||||||
Worker threads (consumers) download the URLs as they are found.
|
|
||||||
"""
|
"""
|
||||||
task_queue = queue.Queue()
|
task_queue = queue.Queue()
|
||||||
num_download_threads = 8
|
num_download_threads = 8
|
||||||
|
|
||||||
download_stats = {'downloaded': 0, 'skipped': 0}
|
download_stats = {'downloaded': 0, 'skipped': 0}
|
||||||
|
failed_files_list = []
|
||||||
|
|
||||||
def downloader_worker():
|
def downloader_worker():
|
||||||
"""The function that each download thread will run."""
|
|
||||||
worker_scraper = cloudscraper.create_scraper()
|
worker_scraper = cloudscraper.create_scraper()
|
||||||
while True:
|
while True:
|
||||||
try:
|
|
||||||
# Get a task from the queue
|
|
||||||
task = task_queue.get()
|
task = task_queue.get()
|
||||||
# The sentinel value to signal the end
|
|
||||||
if task is None:
|
if task is None:
|
||||||
|
task_queue.task_done()
|
||||||
break
|
break
|
||||||
|
|
||||||
filepath, img_url = task
|
filepath, img_url = task
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
progress_callback(f" -> Skip: '{os.path.basename(filepath)}'")
|
# We log skips to show it's checking files
|
||||||
|
progress_callback(f" -> Skip (Exists): '{filename}'")
|
||||||
download_stats['skipped'] += 1
|
download_stats['skipped'] += 1
|
||||||
else:
|
task_queue.task_done()
|
||||||
progress_callback(f" Downloading: '{os.path.basename(filepath)}'...")
|
continue
|
||||||
|
|
||||||
|
# --- RETRY LOGIC START ---
|
||||||
|
success = False
|
||||||
|
# UNCOMMENTED: Log the start of download so you see activity
|
||||||
|
progress_callback(f" Downloading: '{filename}'...")
|
||||||
|
|
||||||
|
for attempt in range(10): # Try 10 times
|
||||||
|
try:
|
||||||
|
if attempt > 0:
|
||||||
|
progress_callback(f" ⚠️ Retrying '{filename}' (Attempt {attempt+1}/10)...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
|
response = worker_scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
download_stats['downloaded'] += 1
|
download_stats['downloaded'] += 1
|
||||||
|
success = True
|
||||||
|
# UNCOMMENTED: Log success
|
||||||
|
progress_callback(f" ✅ Downloaded: '{filename}'")
|
||||||
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
progress_callback(f" ❌ Download failed for task. Error: {e}")
|
if attempt == 9:
|
||||||
download_stats['skipped'] += 1
|
progress_callback(f" ❌ Failed '{filename}' after 10 attempts: {e}")
|
||||||
finally:
|
|
||||||
|
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()
|
task_queue.task_done()
|
||||||
|
|
||||||
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
|
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
|
||||||
@@ -178,27 +226,33 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
|||||||
executor.submit(downloader_worker)
|
executor.submit(downloader_worker)
|
||||||
|
|
||||||
page_number = 1
|
page_number = 1
|
||||||
|
progress_callback(" [Hentai2Read] Scanning pages...") # Initial log
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if check_pause_func(): break
|
if check_pause_func(): break
|
||||||
if page_number > 300: # Safety break
|
if page_number > 300:
|
||||||
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
|
progress_callback(" [Hentai2Read] ⚠️ Safety break: Reached 300 pages.")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Log occasionally to show scanning is alive
|
||||||
|
if page_number % 10 == 0:
|
||||||
|
progress_callback(f" [Hentai2Read] Scanned {page_number} pages so far...")
|
||||||
|
|
||||||
page_url_to_check = f"{chapter_url}{page_number}/"
|
page_url_to_check = f"{chapter_url}{page_number}/"
|
||||||
try:
|
try:
|
||||||
page_response = None
|
page_response = None
|
||||||
page_last_exception = None
|
page_last_exception = None
|
||||||
for page_attempt in range(3): # 3 attempts for sub-pages
|
for page_attempt in range(3):
|
||||||
try:
|
try:
|
||||||
page_response = scraper.get(page_url_to_check, timeout=30)
|
page_response = scraper.get(page_url_to_check, timeout=30)
|
||||||
page_last_exception = None
|
page_last_exception = None
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
page_last_exception = e
|
page_last_exception = e
|
||||||
time.sleep(1) # Short delay for page scraping retries
|
time.sleep(1)
|
||||||
|
|
||||||
if page_last_exception:
|
if page_last_exception:
|
||||||
raise page_last_exception # Give up after 3 tries
|
raise page_last_exception
|
||||||
|
|
||||||
if page_response.history or page_response.status_code != 200:
|
if page_response.history or page_response.status_code != 200:
|
||||||
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
|
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
|
||||||
@@ -209,7 +263,7 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
|||||||
img_src = img_tag.get("src") if img_tag else None
|
img_src = img_tag.get("src") if img_tag else None
|
||||||
|
|
||||||
if not img_tag or img_src == "https://static.hentai.direct/hentai":
|
if not img_tag or img_src == "https://static.hentai.direct/hentai":
|
||||||
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
|
progress_callback(f" [Hentai2Read] End of chapter detected (Last page reached at {page_number}).")
|
||||||
break
|
break
|
||||||
|
|
||||||
normalized_img_src = urljoin(page_response.url, img_src)
|
normalized_img_src = urljoin(page_response.url, img_src)
|
||||||
@@ -220,15 +274,19 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
|||||||
task_queue.put((filepath, normalized_img_src))
|
task_queue.put((filepath, normalized_img_src))
|
||||||
|
|
||||||
page_number += 1
|
page_number += 1
|
||||||
time.sleep(0.1) # Small delay between scraping pages
|
time.sleep(0.1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
|
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Signal workers to exit
|
||||||
for _ in range(num_download_threads):
|
for _ in range(num_download_threads):
|
||||||
task_queue.put(None)
|
task_queue.put(None)
|
||||||
|
|
||||||
|
# Wait for all tasks to complete
|
||||||
|
task_queue.join()
|
||||||
executor.shutdown(wait=True)
|
executor.shutdown(wait=True)
|
||||||
|
|
||||||
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
|
progress_callback(f" Chapter complete. Processed {page_number - 1} images.")
|
||||||
return download_stats['downloaded'], download_stats['skipped']
|
|
||||||
|
return download_stats['downloaded'], download_stats['skipped'], failed_files_list
|
||||||
@@ -5,7 +5,8 @@ import time
|
|||||||
import random
|
import random
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
def get_chapter_list(scraper, series_url, logger_func):
|
# 1. Update arguments to accept proxies=None
|
||||||
|
def get_chapter_list(scraper, series_url, logger_func, proxies=None):
|
||||||
"""
|
"""
|
||||||
Checks if a URL is a series page and returns a list of all chapter URLs if it is.
|
Checks if a URL is a series page and returns a list of all chapter URLs if it is.
|
||||||
Relies on a passed-in scraper session for connection.
|
Relies on a passed-in scraper session for connection.
|
||||||
@@ -16,9 +17,13 @@ def get_chapter_list(scraper, series_url, logger_func):
|
|||||||
response = None
|
response = None
|
||||||
max_retries = 8
|
max_retries = 8
|
||||||
|
|
||||||
|
# 2. Define smart timeout logic
|
||||||
|
req_timeout = (30, 120) if proxies else 30
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = scraper.get(series_url, headers=headers, timeout=30)
|
# 3. Add proxies, verify=False, and the new timeout
|
||||||
|
response = scraper.get(series_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.")
|
logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.")
|
||||||
break
|
break
|
||||||
@@ -53,7 +58,8 @@ def get_chapter_list(scraper, series_url, logger_func):
|
|||||||
logger_func(f" [AllComic] ❌ Error parsing chapters after successful connection: {e}")
|
logger_func(f" [AllComic] ❌ Error parsing chapters after successful connection: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def fetch_chapter_data(scraper, chapter_url, logger_func):
|
# 4. Update arguments here too
|
||||||
|
def fetch_chapter_data(scraper, chapter_url, logger_func, proxies=None):
|
||||||
"""
|
"""
|
||||||
Fetches the comic title, chapter title, and image URLs for a single chapter page.
|
Fetches the comic title, chapter title, and image URLs for a single chapter page.
|
||||||
Relies on a passed-in scraper session for connection.
|
Relies on a passed-in scraper session for connection.
|
||||||
@@ -64,9 +70,14 @@ def fetch_chapter_data(scraper, chapter_url, logger_func):
|
|||||||
|
|
||||||
response = None
|
response = None
|
||||||
max_retries = 8
|
max_retries = 8
|
||||||
|
|
||||||
|
# 5. Define smart timeout logic again
|
||||||
|
req_timeout = (30, 120) if proxies else 30
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = scraper.get(chapter_url, headers=headers, timeout=30)
|
# 6. Add proxies, verify=False, and timeout
|
||||||
|
response = scraper.get(chapter_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
break
|
break
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
|
|||||||
@@ -4,13 +4,37 @@ from urllib.parse import urlparse
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import cloudscraper
|
import cloudscraper
|
||||||
|
import ssl
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.poolmanager import PoolManager
|
||||||
|
|
||||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||||
from ..config.constants import (
|
from ..config.constants import (
|
||||||
STYLE_DATE_POST_TITLE
|
STYLE_DATE_POST_TITLE,
|
||||||
|
STYLE_DATE_BASED,
|
||||||
|
STYLE_POST_TITLE_GLOBAL_NUMBERING
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- NEW: Custom Adapter to fix SSL errors ---
|
||||||
|
class CustomSSLAdapter(HTTPAdapter):
|
||||||
|
"""
|
||||||
|
A custom HTTPAdapter that forces check_hostname=False when using SSL.
|
||||||
|
This prevents the 'Cannot set verify_mode to CERT_NONE' error.
|
||||||
|
"""
|
||||||
|
def init_poolmanager(self, connections, maxsize, block=False):
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
# Crucial: Disable hostname checking FIRST, then set verify mode
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
self.poolmanager = PoolManager(
|
||||||
|
num_pools=connections,
|
||||||
|
maxsize=maxsize,
|
||||||
|
block=block,
|
||||||
|
ssl_context=ctx
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None, proxies=None):
|
||||||
"""
|
"""
|
||||||
Fetches a single page of posts from the API with robust retry logic.
|
Fetches a single page of posts from the API with robust retry logic.
|
||||||
"""
|
"""
|
||||||
@@ -23,7 +47,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
|||||||
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
logger(" Post fetching resumed.")
|
logger(" Post fetching resumed.")
|
||||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
|
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags,content"
|
||||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||||
|
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
@@ -38,8 +62,11 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
|||||||
log_message += f" (Attempt {attempt + 1}/{max_retries})"
|
log_message += f" (Attempt {attempt + 1}/{max_retries})"
|
||||||
logger(log_message)
|
logger(log_message)
|
||||||
|
|
||||||
|
request_timeout = (30, 120) if proxies else (15, 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
|
with requests.get(paginated_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False) as response:
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
response.encoding = 'utf-8'
|
response.encoding = 'utf-8'
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -79,18 +106,38 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
|||||||
|
|
||||||
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
|
||||||
|
|
||||||
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
|
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None, proxies=None):
|
||||||
"""
|
"""
|
||||||
--- MODIFIED FUNCTION ---
|
|
||||||
Fetches the full data, including the 'content' field, for a single post using cloudscraper.
|
Fetches the full data, including the 'content' field, for a single post using cloudscraper.
|
||||||
|
Includes RETRY logic for 429 Rate Limit errors.
|
||||||
"""
|
"""
|
||||||
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
|
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
|
||||||
logger(f" Fetching full content for post ID {post_id}...")
|
logger(f" Fetching full content for post ID {post_id}...")
|
||||||
|
|
||||||
|
# Retry settings
|
||||||
|
max_retries = 4
|
||||||
|
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
scraper = None
|
||||||
|
try:
|
||||||
scraper = cloudscraper.create_scraper()
|
scraper = cloudscraper.create_scraper()
|
||||||
|
|
||||||
try:
|
# Mount custom SSL adapter
|
||||||
response = scraper.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict)
|
adapter = CustomSSLAdapter()
|
||||||
|
scraper.mount("https://", adapter)
|
||||||
|
|
||||||
|
request_timeout = (30, 300) if proxies else (15, 300)
|
||||||
|
|
||||||
|
response = scraper.get(post_api_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False)
|
||||||
|
|
||||||
|
# --- FIX: Handle 429 Rate Limit explicitly ---
|
||||||
|
if response.status_code == 429:
|
||||||
|
wait_time = 20 + (attempt * 10) # 20s, 30s, 40s...
|
||||||
|
logger(f" ⚠️ Rate Limited (429) on post {post_id}. Waiting {wait_time} seconds before retrying...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
continue # Try loop again
|
||||||
|
# ---------------------------------------------
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
full_post_data = response.json()
|
full_post_data = response.json()
|
||||||
@@ -102,11 +149,24 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
|
|||||||
return full_post_data
|
return full_post_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
|
# Catch "Too Many Requests" if it wasn't caught by status_code check above
|
||||||
|
if "429" in str(e) or "Too Many Requests" in str(e):
|
||||||
|
if attempt < max_retries:
|
||||||
|
wait_time = 20 + (attempt * 10)
|
||||||
|
logger(f" ⚠️ Rate Limit Error caught: {e}. Waiting {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Only log error if this was the last attempt
|
||||||
|
if attempt == max_retries:
|
||||||
|
logger(f" ❌ Failed to fetch full content for post {post_id} after {max_retries} retries: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if scraper:
|
||||||
|
scraper.close()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None, proxies=None):
|
||||||
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
|
|
||||||
"""Fetches all comments for a specific post."""
|
"""Fetches all comments for a specific post."""
|
||||||
if cancellation_event and cancellation_event.is_set():
|
if cancellation_event and cancellation_event.is_set():
|
||||||
raise RuntimeError("Comment fetch operation cancelled by user.")
|
raise RuntimeError("Comment fetch operation cancelled by user.")
|
||||||
@@ -115,7 +175,9 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
|
|||||||
logger(f" Fetching comments: {comments_api_url}")
|
logger(f" Fetching comments: {comments_api_url}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict)
|
request_timeout = (30, 60) if proxies else (10, 30)
|
||||||
|
|
||||||
|
with requests.get(comments_api_url, headers=headers, timeout=request_timeout, cookies=cookies_dict, proxies=proxies, verify=False) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
response.encoding = 'utf-8'
|
response.encoding = 'utf-8'
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -138,7 +200,8 @@ def download_from_api(
|
|||||||
app_base_dir=None,
|
app_base_dir=None,
|
||||||
manga_filename_style_for_sort_check=None,
|
manga_filename_style_for_sort_check=None,
|
||||||
processed_post_ids=None,
|
processed_post_ids=None,
|
||||||
fetch_all_first=False
|
fetch_all_first=False,
|
||||||
|
proxies=None
|
||||||
):
|
):
|
||||||
parsed_input_url_for_domain = urlparse(api_url_input)
|
parsed_input_url_for_domain = urlparse(api_url_input)
|
||||||
api_domain = parsed_input_url_for_domain.netloc
|
api_domain = parsed_input_url_for_domain.netloc
|
||||||
@@ -174,10 +237,13 @@ def download_from_api(
|
|||||||
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
|
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
|
||||||
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
|
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
|
||||||
try:
|
try:
|
||||||
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
|
request_timeout = (30, 60) if proxies else (10, 30)
|
||||||
|
|
||||||
|
with requests.get(direct_post_api_url, headers=headers, timeout=request_timeout, cookies=cookies_for_api, proxies=proxies, verify=False) as direct_response:
|
||||||
direct_response.raise_for_status()
|
direct_response.raise_for_status()
|
||||||
direct_response.encoding = 'utf-8'
|
direct_response.encoding = 'utf-8'
|
||||||
direct_post_data = direct_response.json()
|
direct_post_data = direct_response.json()
|
||||||
|
|
||||||
if isinstance(direct_post_data, list) and direct_post_data:
|
if isinstance(direct_post_data, list) and direct_post_data:
|
||||||
direct_post_data = direct_post_data[0]
|
direct_post_data = direct_post_data[0]
|
||||||
if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
|
if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
|
||||||
@@ -200,12 +266,23 @@ def download_from_api(
|
|||||||
if target_post_id and (start_page or end_page):
|
if target_post_id and (start_page or end_page):
|
||||||
logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
|
logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
|
||||||
|
|
||||||
is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id
|
# --- FIXED LOGIC HERE ---
|
||||||
|
# Define which styles require fetching ALL posts first (Sequential Mode)
|
||||||
|
styles_requiring_fetch_all = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||||
|
|
||||||
|
# Only enable "fetch all and sort" if the current style is explicitly in the list above
|
||||||
|
is_manga_mode_fetch_all_and_sort_oldest_first = (
|
||||||
|
manga_mode and
|
||||||
|
(manga_filename_style_for_sort_check in styles_requiring_fetch_all) and
|
||||||
|
not target_post_id
|
||||||
|
)
|
||||||
|
|
||||||
should_fetch_all = fetch_all_first or is_manga_mode_fetch_all_and_sort_oldest_first
|
should_fetch_all = fetch_all_first or is_manga_mode_fetch_all_and_sort_oldest_first
|
||||||
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/posts"
|
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/posts"
|
||||||
page_size = 50
|
page_size = 50
|
||||||
|
|
||||||
if is_manga_mode_fetch_all_and_sort_oldest_first:
|
if is_manga_mode_fetch_all_and_sort_oldest_first:
|
||||||
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
|
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check} - Oldest First Sort Active): Fetching all posts to sort by date...")
|
||||||
all_posts_for_manga_mode = []
|
all_posts_for_manga_mode = []
|
||||||
current_offset_manga = 0
|
current_offset_manga = 0
|
||||||
if start_page and start_page > 1:
|
if start_page and start_page > 1:
|
||||||
@@ -232,7 +309,7 @@ def download_from_api(
|
|||||||
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
|
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
|
||||||
if not isinstance(posts_batch_manga, list):
|
if not isinstance(posts_batch_manga, list):
|
||||||
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
|
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
|
||||||
break
|
break
|
||||||
@@ -300,8 +377,9 @@ def download_from_api(
|
|||||||
yield all_posts_for_manga_mode[i:i + page_size]
|
yield all_posts_for_manga_mode[i:i + page_size]
|
||||||
return
|
return
|
||||||
|
|
||||||
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check == STYLE_DATE_POST_TITLE):
|
# Log specific message for styles that are in Manga Mode but NOT sorting (Streaming)
|
||||||
logger(f" Manga Mode (Style: {STYLE_DATE_POST_TITLE}): Processing posts in default API order (newest first).")
|
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check not in styles_requiring_fetch_all):
|
||||||
|
logger(f" Renaming Mode (Style: {manga_filename_style_for_sort_check}): Processing posts in default API order (Streaming).")
|
||||||
|
|
||||||
current_page_num = 1
|
current_page_num = 1
|
||||||
current_offset = 0
|
current_offset = 0
|
||||||
@@ -311,7 +389,6 @@ def download_from_api(
|
|||||||
current_page_num = start_page
|
current_page_num = start_page
|
||||||
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
|
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
|
||||||
|
|
||||||
# --- START OF MODIFIED BLOCK ---
|
|
||||||
while True:
|
while True:
|
||||||
if pause_event and pause_event.is_set():
|
if pause_event and pause_event.is_set():
|
||||||
logger(" Post fetching loop paused...")
|
logger(" Post fetching loop paused...")
|
||||||
@@ -334,8 +411,7 @@ def download_from_api(
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Fetch the raw batch of posts
|
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api, proxies=proxies)
|
||||||
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
|
||||||
if not isinstance(raw_posts_batch, list):
|
if not isinstance(raw_posts_batch, list):
|
||||||
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||||
break
|
break
|
||||||
@@ -350,7 +426,6 @@ def download_from_api(
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
break
|
break
|
||||||
|
|
||||||
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
|
|
||||||
if not raw_posts_batch:
|
if not raw_posts_batch:
|
||||||
if target_post_id and not processed_target_post_flag:
|
if target_post_id and not processed_target_post_flag:
|
||||||
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
|
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
|
||||||
@@ -359,9 +434,8 @@ def download_from_api(
|
|||||||
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
|
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
|
||||||
else:
|
else:
|
||||||
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
|
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
|
||||||
break # This break is now correct.
|
break
|
||||||
|
|
||||||
# 3. Filter the batch against processed IDs
|
|
||||||
posts_batch_to_yield = raw_posts_batch
|
posts_batch_to_yield = raw_posts_batch
|
||||||
original_count = len(raw_posts_batch)
|
original_count = len(raw_posts_batch)
|
||||||
|
|
||||||
@@ -371,25 +445,17 @@ def download_from_api(
|
|||||||
if skipped_count > 0:
|
if skipped_count > 0:
|
||||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||||
|
|
||||||
# 4. Process the *filtered* batch
|
|
||||||
if target_post_id and not processed_target_post_flag:
|
if target_post_id and not processed_target_post_flag:
|
||||||
# Still searching for a specific post
|
|
||||||
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
|
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
|
||||||
if matching_post:
|
if matching_post:
|
||||||
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
|
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
|
||||||
yield [matching_post]
|
yield [matching_post]
|
||||||
processed_target_post_flag = True
|
processed_target_post_flag = True
|
||||||
elif not target_post_id:
|
elif not target_post_id:
|
||||||
# Downloading a creator feed
|
|
||||||
if posts_batch_to_yield:
|
if posts_batch_to_yield:
|
||||||
# We found new posts on this page, yield them
|
|
||||||
yield posts_batch_to_yield
|
yield posts_batch_to_yield
|
||||||
elif original_count > 0:
|
elif original_count > 0:
|
||||||
# We found 0 new posts, but the page *did* have posts (they were just skipped).
|
|
||||||
# Log this and continue to the next page.
|
|
||||||
logger(f" No new posts found on page {current_page_num}. Checking next page...")
|
logger(f" No new posts found on page {current_page_num}. Checking next page...")
|
||||||
# If original_count was 0, the `if not raw_posts_batch:` check
|
|
||||||
# already caught it and broke the loop.
|
|
||||||
|
|
||||||
if processed_target_post_flag:
|
if processed_target_post_flag:
|
||||||
break
|
break
|
||||||
@@ -397,7 +463,6 @@ def download_from_api(
|
|||||||
current_offset += page_size
|
current_offset += page_size
|
||||||
current_page_num += 1
|
current_page_num += 1
|
||||||
time.sleep(0.6)
|
time.sleep(0.6)
|
||||||
# --- END OF MODIFIED BLOCK ---
|
|
||||||
|
|
||||||
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
|
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
|
||||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||||
193
src/core/deviantart_client.py
Normal file
193
src/core/deviantart_client.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
# 1. Accept proxies in init
|
||||||
|
def __init__(self, logger_func=print, proxies=None):
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
# 2. Configure Session with Proxy & SSL settings immediately
|
||||||
|
if proxies:
|
||||||
|
self.session.proxies.update(proxies)
|
||||||
|
self.session.verify = False # Ignore SSL for proxies
|
||||||
|
self.proxies_enabled = True
|
||||||
|
else:
|
||||||
|
self.proxies_enabled = False
|
||||||
|
|
||||||
|
self.session.headers.update({
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
# 3. Smart timeout (longer if proxy)
|
||||||
|
req_timeout = 30 if self.proxies_enabled else 10
|
||||||
|
|
||||||
|
resp = self.session.post(url, data=data, timeout=req_timeout)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 4. Smart timeout
|
||||||
|
req_timeout = 30 if self.proxies_enabled else 20
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
resp = self.session.get(url, params=params, timeout=req_timeout)
|
||||||
|
|
||||||
|
# 429: Rate Limit
|
||||||
|
if resp.status_code == 429:
|
||||||
|
retry_after = resp.headers.get('Retry-After')
|
||||||
|
if retry_after:
|
||||||
|
sleep_time = int(retry_after) + 2 # Add buffer
|
||||||
|
else:
|
||||||
|
# 5. Increase default wait time for 429s
|
||||||
|
sleep_time = 15
|
||||||
|
|
||||||
|
self._log_once(sleep_time, f" [DeviantArt] ⚠️ Rate limit (429). Sleeping {sleep_time}s...")
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 401: Token Expired (Refresh and Retry)
|
||||||
|
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")
|
||||||
|
|
||||||
|
if 400 <= resp.status_code < 500:
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
if 500 <= resp.status_code < 600:
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
with self.log_lock:
|
||||||
|
self.logged_waits.clear()
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response is not None and 400 <= e.response.status_code < 500:
|
||||||
|
raise e
|
||||||
|
pass
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if retries < max_retries:
|
||||||
|
self._log_once("conn_error", f" [DeviantArt] Connection error: {e}. Retrying...")
|
||||||
|
time.sleep(backoff_delay)
|
||||||
|
retries += 1
|
||||||
|
continue
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _log_once(self, key, message):
|
||||||
|
"""Helper to avoid spamming the same log message during loops."""
|
||||||
|
should_log = False
|
||||||
|
with self.log_lock:
|
||||||
|
if key not in self.logged_waits:
|
||||||
|
self.logged_waits.add(key)
|
||||||
|
should_log = True
|
||||||
|
if should_log:
|
||||||
|
self.logger(message)
|
||||||
|
|
||||||
|
def get_deviation_uuid(self, url):
|
||||||
|
"""Scrapes the deviation page to find the UUID."""
|
||||||
|
try:
|
||||||
|
req_timeout = 30 if self.proxies_enabled else 15
|
||||||
|
resp = self.session.get(url, timeout=req_timeout)
|
||||||
|
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
|
||||||
0
src/core/hentaifox.txt
Normal file
0
src/core/hentaifox.txt
Normal file
60
src/core/hentaifox_client.py
Normal file
60
src/core/hentaifox_client.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from bs4 import BeautifulSoup # Optional, but regex is faster for this specific site
|
||||||
|
|
||||||
|
# Logic derived from NHdownloader.sh 'hentaifox' function
|
||||||
|
BASE_URL = "https://hentaifox.com"
|
||||||
|
HEADERS = {
|
||||||
|
"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",
|
||||||
|
"Referer": "https://hentaifox.com/",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_gallery_id(url_or_id):
|
||||||
|
"""Extracts numbers from URL or returns the ID string."""
|
||||||
|
match = re.search(r"(\d+)", str(url_or_id))
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
def get_gallery_metadata(gallery_id):
|
||||||
|
"""
|
||||||
|
Fetches the main gallery page to get the Title and Total Pages.
|
||||||
|
Equivalent to the first part of the 'hentaifox' function in .sh file.
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/gallery/{gallery_id}/"
|
||||||
|
response = requests.get(url, headers=HEADERS)
|
||||||
|
response.raise_for_status()
|
||||||
|
html = response.text
|
||||||
|
|
||||||
|
# Extract Title (Bash: grep -o '<title>.*</title>')
|
||||||
|
title_match = re.search(r'<title>(.*?)</title>', html)
|
||||||
|
title = title_match.group(1).replace(" - HentaiFox", "").strip() if title_match else f"Gallery {gallery_id}"
|
||||||
|
|
||||||
|
# Extract Total Pages (Bash: grep -Eo 'Pages: [0-9]*')
|
||||||
|
pages_match = re.search(r'Pages: (\d+)', html)
|
||||||
|
if not pages_match:
|
||||||
|
raise ValueError("Could not find total pages count.")
|
||||||
|
|
||||||
|
total_pages = int(pages_match.group(1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": gallery_id,
|
||||||
|
"title": title,
|
||||||
|
"total_pages": total_pages
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_image_link_for_page(gallery_id, page_num):
|
||||||
|
"""
|
||||||
|
Fetches the specific reader page to find the actual image URL.
|
||||||
|
Equivalent to the loop in the 'hentaifox' function:
|
||||||
|
url="https://hentaifox.com/g/${id}/${i}/"
|
||||||
|
"""
|
||||||
|
url = f"{BASE_URL}/g/{gallery_id}/{page_num}/"
|
||||||
|
response = requests.get(url, headers=HEADERS)
|
||||||
|
|
||||||
|
# Extract image source (Bash: grep -Eo 'data-src="..."')
|
||||||
|
# Regex looks for: data-src="https://..."
|
||||||
|
match = re.search(r'data-src="(https://[^"]+)"', response.text)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
@@ -3,7 +3,7 @@ import time
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
|
from concurrent.futures import ThreadPoolExecutor, as_completed, Future, CancelledError
|
||||||
from .api_client import download_from_api
|
from .api_client import download_from_api
|
||||||
from .workers import PostProcessorWorker
|
from .workers import PostProcessorWorker
|
||||||
from ..config.constants import (
|
from ..config.constants import (
|
||||||
@@ -84,8 +84,18 @@ class DownloadManager:
|
|||||||
|
|
||||||
is_single_post = bool(config.get('target_post_id_from_initial_url'))
|
is_single_post = bool(config.get('target_post_id_from_initial_url'))
|
||||||
use_multithreading = config.get('use_multithreading', True)
|
use_multithreading = config.get('use_multithreading', True)
|
||||||
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
|
||||||
|
|
||||||
|
# --- FIXED LOGIC: Strict check for sequential fetch modes ---
|
||||||
|
# Only "Date Based" and "Title + Global Numbering" require fetching the full list first.
|
||||||
|
# "Custom", "Date + Title", "Original Name", and "Post ID" will now use the pool (streaming).
|
||||||
|
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||||
|
|
||||||
|
is_manga_sequential = (
|
||||||
|
config.get('manga_mode_active') and
|
||||||
|
config.get('manga_filename_style') in sequential_styles
|
||||||
|
)
|
||||||
|
|
||||||
|
# If it is NOT a strictly sequential manga mode, we use the pool (fetch-as-we-go)
|
||||||
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
|
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
|
||||||
|
|
||||||
if should_use_multithreading_for_posts:
|
if should_use_multithreading_for_posts:
|
||||||
@@ -97,12 +107,34 @@ class DownloadManager:
|
|||||||
fetcher_thread.start()
|
fetcher_thread.start()
|
||||||
else:
|
else:
|
||||||
# Single-threaded mode does not use the manager's complex logic
|
# Single-threaded mode does not use the manager's complex logic
|
||||||
self._log("ℹ️ Manager is handing off to a single-threaded worker...")
|
self._log("ℹ️ Manager is handing off to a single-threaded worker (Sequential Mode)...")
|
||||||
# The single-threaded worker will manage its own lifecycle and signals.
|
# The single-threaded worker will manage its own lifecycle and signals.
|
||||||
# The manager's role for this session is effectively over.
|
# The manager's role for this session is effectively over.
|
||||||
self.is_running = False # Allow another session to start if needed
|
self.is_running = False # Allow another session to start if needed
|
||||||
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)})
|
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)})
|
||||||
|
|
||||||
|
def _get_proxies_from_config(self, config):
|
||||||
|
"""Constructs the proxy dictionary from the config."""
|
||||||
|
if not config.get('proxy_enabled'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
host = config.get('proxy_host')
|
||||||
|
port = config.get('proxy_port')
|
||||||
|
if not host or not port:
|
||||||
|
return None
|
||||||
|
|
||||||
|
proxy_str = f"http://{host}:{port}"
|
||||||
|
|
||||||
|
# Add auth if provided
|
||||||
|
user = config.get('proxy_username')
|
||||||
|
password = config.get('proxy_password')
|
||||||
|
if user and password:
|
||||||
|
proxy_str = f"http://{user}:{password}@{host}:{port}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"http": proxy_str,
|
||||||
|
"https": proxy_str
|
||||||
|
}
|
||||||
|
|
||||||
def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
|
def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
|
||||||
"""
|
"""
|
||||||
@@ -118,6 +150,9 @@ class DownloadManager:
|
|||||||
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
|
||||||
processed_ids = session_processed_ids.union(profile_processed_ids)
|
processed_ids = session_processed_ids.union(profile_processed_ids)
|
||||||
|
|
||||||
|
# Helper to get proxies
|
||||||
|
proxies = self._get_proxies_from_config(config)
|
||||||
|
|
||||||
if restore_data and 'all_posts_data' in restore_data:
|
if restore_data and 'all_posts_data' in restore_data:
|
||||||
# This logic for session restore remains as it relies on a pre-fetched list
|
# This logic for session restore remains as it relies on a pre-fetched list
|
||||||
all_posts = restore_data['all_posts_data']
|
all_posts = restore_data['all_posts_data']
|
||||||
@@ -132,127 +167,113 @@ class DownloadManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for post_data in posts_to_process:
|
for post_data in posts_to_process:
|
||||||
if self.cancellation_event.is_set(): break
|
if self.cancellation_event.is_set():
|
||||||
worker = PostProcessorWorker(post_data, config, self.progress_queue)
|
break
|
||||||
|
|
||||||
|
worker_args = self._map_config_to_worker_args(post_data, config)
|
||||||
|
# Manually inject proxies here if _map_config_to_worker_args didn't catch it (though it should)
|
||||||
|
worker_args['proxies'] = proxies
|
||||||
|
|
||||||
|
worker = PostProcessorWorker(**worker_args)
|
||||||
future = self.thread_pool.submit(worker.process)
|
future = self.thread_pool.submit(worker.process)
|
||||||
future.add_done_callback(self._handle_future_result)
|
future.add_done_callback(self._handle_future_result)
|
||||||
self.active_futures.append(future)
|
self.active_futures.append(future)
|
||||||
else:
|
else:
|
||||||
# --- START: REFACTORED STREAMING LOGIC ---
|
# --- Streaming Logic ---
|
||||||
|
if proxies:
|
||||||
|
self._log(f" 🌐 Using Proxy: {config.get('proxy_host')}:{config.get('proxy_port')}")
|
||||||
|
|
||||||
post_generator = download_from_api(
|
post_generator = download_from_api(
|
||||||
api_url_input=config['api_url'],
|
api_url_input=config['api_url'],
|
||||||
logger=self._log,
|
logger=self._log,
|
||||||
start_page=config.get('start_page'),
|
start_page=config.get('start_page'),
|
||||||
end_page=config.get('end_page'),
|
end_page=config.get('end_page'),
|
||||||
manga_mode=config.get('manga_mode_active', False),
|
|
||||||
cancellation_event=self.cancellation_event,
|
cancellation_event=self.cancellation_event,
|
||||||
pause_event=self.pause_event,
|
pause_event=self.pause_event,
|
||||||
use_cookie=config.get('use_cookie', False),
|
cookies_dict=None, # Cookie handling handled inside client if needed
|
||||||
cookie_text=config.get('cookie_text', ''),
|
proxies=proxies # <--- NEW: Pass proxies to API client
|
||||||
selected_cookie_file=config.get('selected_cookie_file'),
|
|
||||||
app_base_dir=config.get('app_base_dir'),
|
|
||||||
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
|
|
||||||
processed_post_ids=list(processed_ids)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.total_posts = 0
|
for post_batch in post_generator:
|
||||||
self.processed_posts = 0
|
|
||||||
|
|
||||||
# Process posts in batches as they are yielded by the API client
|
|
||||||
for batch in post_generator:
|
|
||||||
if self.cancellation_event.is_set():
|
if self.cancellation_event.is_set():
|
||||||
self._log(" Post fetching cancelled.")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Filter out any posts that might have been processed since the start
|
if not post_batch:
|
||||||
posts_in_batch_to_process = [p for p in batch if p.get('id') not in processed_ids]
|
|
||||||
|
|
||||||
if not posts_in_batch_to_process:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update total count and immediately inform the UI
|
new_posts_batch = [p for p in post_batch if p.get('id') not in processed_ids]
|
||||||
self.total_posts += len(posts_in_batch_to_process)
|
|
||||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
if not new_posts_batch:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update total posts dynamically as we find them
|
||||||
|
self.total_posts += len(new_posts_batch)
|
||||||
|
|
||||||
|
for post_data in new_posts_batch:
|
||||||
|
if self.cancellation_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# MAPPING CONFIG TO WORKER ARGS
|
||||||
|
worker_args = self._map_config_to_worker_args(post_data, config)
|
||||||
|
worker = PostProcessorWorker(**worker_args)
|
||||||
|
|
||||||
for post_data in posts_in_batch_to_process:
|
|
||||||
if self.cancellation_event.is_set(): break
|
|
||||||
worker = PostProcessorWorker(post_data, config, self.progress_queue)
|
|
||||||
future = self.thread_pool.submit(worker.process)
|
future = self.thread_pool.submit(worker.process)
|
||||||
future.add_done_callback(self._handle_future_result)
|
future.add_done_callback(self._handle_future_result)
|
||||||
self.active_futures.append(future)
|
self.active_futures.append(future)
|
||||||
|
|
||||||
if self.total_posts == 0 and not self.cancellation_event.is_set():
|
# Small sleep to prevent UI freeze
|
||||||
self._log("✅ No new posts found to process.")
|
time.sleep(0.01)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
|
self._log(f"❌ Critical Error in Fetcher Thread: {e}")
|
||||||
self._log(traceback.format_exc())
|
traceback.print_exc()
|
||||||
finally:
|
finally:
|
||||||
if self.thread_pool:
|
self.is_running = False # Mark as not running so we can finish
|
||||||
self.thread_pool.shutdown(wait=True)
|
# The main window checks active futures, so we just exit this thread.
|
||||||
self.is_running = False
|
|
||||||
self._log("🏁 All processing tasks have completed or been cancelled.")
|
|
||||||
self.progress_queue.put({
|
|
||||||
'type': 'finished',
|
|
||||||
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
|
|
||||||
})
|
|
||||||
|
|
||||||
def _handle_future_result(self, future: Future):
|
def _map_config_to_worker_args(self, post_data, config):
|
||||||
"""Callback executed when a worker task completes."""
|
"""Helper to map the flat config dict to PostProcessorWorker arguments."""
|
||||||
if self.cancellation_event.is_set():
|
# Get proxy dict
|
||||||
return
|
proxies = self._get_proxies_from_config(config)
|
||||||
|
|
||||||
with threading.Lock(): # Protect shared counters
|
# This mirrors the arguments in workers.py PostProcessorWorker.__init__
|
||||||
self.processed_posts += 1
|
return {
|
||||||
try:
|
'post_data': post_data,
|
||||||
if future.cancelled():
|
'download_root': config.get('output_dir'),
|
||||||
self._log("⚠️ A post processing task was cancelled.")
|
'known_names': [], # If needed, pass KNOWN_NAMES or load them
|
||||||
self.total_skips += 1
|
'filter_character_list': [], # Parsed filters if available in config
|
||||||
else:
|
'emitter': self.progress_queue,
|
||||||
result = future.result()
|
'unwanted_keywords': set(), # Parse if needed
|
||||||
(dl_count, skip_count, kept_originals,
|
'filter_mode': config.get('filter_mode'),
|
||||||
retryable, permanent, history) = result
|
'skip_zip': config.get('skip_zip'),
|
||||||
self.total_downloads += dl_count
|
'use_subfolders': config.get('use_subfolders'),
|
||||||
self.total_skips += skip_count
|
'use_post_subfolders': config.get('use_post_subfolders'),
|
||||||
self.all_kept_original_filenames.extend(kept_originals)
|
'target_post_id_from_initial_url': config.get('target_post_id_from_initial_url'),
|
||||||
if retryable:
|
'custom_folder_name': config.get('custom_folder_name'),
|
||||||
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
|
'compress_images': config.get('compress_images'),
|
||||||
if permanent:
|
'download_thumbnails': config.get('download_thumbnails'),
|
||||||
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
|
'service': config.get('service') or 'unknown',
|
||||||
if history:
|
'user_id': config.get('user_id') or 'unknown',
|
||||||
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
|
'pause_event': self.pause_event,
|
||||||
post_id = history.get('post_id')
|
'api_url_input': config.get('api_url'),
|
||||||
if post_id and self.current_creator_profile_path:
|
'cancellation_event': self.cancellation_event,
|
||||||
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
|
'downloaded_files': None,
|
||||||
if post_id not in profile_data.get('processed_post_ids', []):
|
'downloaded_file_hashes': None,
|
||||||
profile_data.setdefault('processed_post_ids', []).append(post_id)
|
'downloaded_files_lock': None,
|
||||||
self._save_creator_profile(profile_data)
|
'downloaded_file_hashes_lock': None,
|
||||||
|
'manga_mode_active': config.get('manga_mode_active'),
|
||||||
except Exception as e:
|
'manga_filename_style': config.get('manga_filename_style'),
|
||||||
self._log(f"❌ Worker task resulted in an exception: {e}")
|
'manga_custom_filename_format': config.get('custom_manga_filename_format', "{published} {title}"),
|
||||||
self.total_skips += 1 # Count errored posts as skipped
|
'manga_custom_date_format': config.get('manga_custom_date_format', "YYYY-MM-DD"),
|
||||||
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
'use_multithreading': config.get('use_multithreading', True),
|
||||||
|
'proxies': proxies, # <--- NEW: Pass proxies to worker
|
||||||
|
}
|
||||||
|
|
||||||
def _setup_creator_profile(self, config):
|
def _setup_creator_profile(self, config):
|
||||||
"""Prepares the path and loads data for the current creator's profile."""
|
"""Prepares the path and loads data for the current creator's profile."""
|
||||||
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
|
# Extract name logic here or assume config has it
|
||||||
if not self.current_creator_name_for_profile:
|
self.current_creator_name_for_profile = "Unknown"
|
||||||
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
|
# You should ideally extract name from URL or config here if available
|
||||||
return {}
|
|
||||||
|
|
||||||
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
|
|
||||||
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
|
||||||
os.makedirs(self.creator_profiles_dir, exist_ok=True)
|
|
||||||
|
|
||||||
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
|
|
||||||
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
|
|
||||||
|
|
||||||
if os.path.exists(self.current_creator_profile_path):
|
|
||||||
try:
|
|
||||||
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _save_creator_profile(self, data):
|
def _save_creator_profile(self, data):
|
||||||
@@ -280,6 +301,33 @@ class DownloadManager:
|
|||||||
self.cancellation_event.set()
|
self.cancellation_event.set()
|
||||||
|
|
||||||
if self.thread_pool:
|
if self.thread_pool:
|
||||||
self._log(" Signaling all worker threads to stop and shutting down pool...")
|
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
self.thread_pool.shutdown(wait=False)
|
|
||||||
|
|
||||||
|
def _handle_future_result(self, future):
|
||||||
|
"""Callback for when a worker task finishes."""
|
||||||
|
if self.active_futures:
|
||||||
|
try:
|
||||||
|
self.active_futures.remove(future)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
# result tuple: (download_count, skip_count, kept_original_filenames, ...)
|
||||||
|
if result:
|
||||||
|
self.total_downloads += result[0]
|
||||||
|
self.total_skips += result[1]
|
||||||
|
if len(result) > 3 and result[3]:
|
||||||
|
# filename was kept original
|
||||||
|
pass
|
||||||
|
except CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f"❌ Worker Error: {e}")
|
||||||
|
|
||||||
|
self.processed_posts += 1
|
||||||
|
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
|
||||||
|
|
||||||
|
if not self.active_futures and not self.is_running:
|
||||||
|
self._log("✅ All tasks completed.")
|
||||||
|
self.progress_queue.put({'type': 'worker_finished', 'payload': (self.total_downloads, self.total_skips, [], [])})
|
||||||
@@ -1,31 +1,35 @@
|
|||||||
import requests
|
import requests
|
||||||
import cloudscraper
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def fetch_nhentai_gallery(gallery_id, logger=print):
|
# 1. Update arguments to accept proxies=None
|
||||||
|
def fetch_nhentai_gallery(gallery_id, logger=print, proxies=None):
|
||||||
"""
|
"""
|
||||||
Fetches the metadata for a single nhentai gallery using cloudscraper to bypass Cloudflare.
|
Fetches the metadata for a single nhentai gallery.
|
||||||
|
Switched to standard requests to support proxies with self-signed certs.
|
||||||
Args:
|
|
||||||
gallery_id (str or int): The ID of the nhentai gallery.
|
|
||||||
logger (function): A function to log progress and error messages.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: A dictionary containing the gallery's metadata if successful, otherwise None.
|
|
||||||
"""
|
"""
|
||||||
api_url = f"https://nhentai.net/api/gallery/{gallery_id}"
|
api_url = f"https://nhentai.net/api/gallery/{gallery_id}"
|
||||||
|
|
||||||
scraper = cloudscraper.create_scraper()
|
# 2. Use a real User-Agent to avoid immediate blocking
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
|
||||||
|
}
|
||||||
|
|
||||||
logger(f" Fetching nhentai gallery metadata from: {api_url}")
|
logger(f" Fetching nhentai gallery metadata from: {api_url}")
|
||||||
|
|
||||||
|
# 3. Smart timeout logic
|
||||||
|
req_timeout = (30, 120) if proxies else 20
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use the scraper to make the GET request
|
# 4. Use requests.get with proxies, verify=False, and timeout
|
||||||
response = scraper.get(api_url, timeout=20)
|
response = requests.get(api_url, headers=headers, timeout=req_timeout, proxies=proxies, verify=False)
|
||||||
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
logger(f" ❌ Gallery not found (404): ID {gallery_id}")
|
logger(f" ❌ Gallery not found (404): ID {gallery_id}")
|
||||||
return None
|
return None
|
||||||
|
elif response.status_code == 403:
|
||||||
|
logger(f" ❌ Access Denied (403): Cloudflare blocked the request. Try a different proxy or User-Agent.")
|
||||||
|
return None
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -36,9 +40,9 @@ def fetch_nhentai_gallery(gallery_id, logger=print):
|
|||||||
gallery_data['pages'] = gallery_data.pop('images')['pages']
|
gallery_data['pages'] = gallery_data.pop('images')['pages']
|
||||||
return gallery_data
|
return gallery_data
|
||||||
else:
|
else:
|
||||||
logger(" ❌ API response is missing essential keys (id, media_id, or images).")
|
logger(" ❌ API response is missing essential keys (id, media_id, images).")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger(f" ❌ An error occurred while fetching gallery {gallery_id}: {e}")
|
logger(f" ❌ Error fetching nhentai metadata: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -56,12 +56,14 @@ from ..utils.text_utils import (
|
|||||||
match_folders_from_title, match_folders_from_filename_enhanced
|
match_folders_from_title, match_folders_from_filename_enhanced
|
||||||
)
|
)
|
||||||
from ..config.constants import *
|
from ..config.constants import *
|
||||||
|
from ..ui.dialogs.SinglePDF import create_individual_pdf
|
||||||
|
|
||||||
def robust_clean_name(name):
|
def robust_clean_name(name):
|
||||||
"""A more robust function to remove illegal characters for filenames and folders."""
|
"""A more robust function to remove illegal characters for filenames and folders."""
|
||||||
if not name:
|
if not name:
|
||||||
return ""
|
return ""
|
||||||
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*\'\[\]]'
|
# FIX: Removed \' from the list so apostrophes are kept
|
||||||
|
illegal_chars_pattern = r'[\x00-\x1f<>:"/\\|?*]'
|
||||||
cleaned_name = re.sub(illegal_chars_pattern, '', name)
|
cleaned_name = re.sub(illegal_chars_pattern, '', name)
|
||||||
|
|
||||||
cleaned_name = cleaned_name.strip(' .')
|
cleaned_name = cleaned_name.strip(' .')
|
||||||
@@ -132,6 +134,9 @@ class PostProcessorWorker:
|
|||||||
sfp_threshold=None,
|
sfp_threshold=None,
|
||||||
handle_unknown_mode=False,
|
handle_unknown_mode=False,
|
||||||
creator_name_cache=None,
|
creator_name_cache=None,
|
||||||
|
add_info_in_pdf=False,
|
||||||
|
proxies=None
|
||||||
|
|
||||||
):
|
):
|
||||||
self.post = post_data
|
self.post = post_data
|
||||||
self.download_root = download_root
|
self.download_root = download_root
|
||||||
@@ -205,6 +210,9 @@ class PostProcessorWorker:
|
|||||||
self.sfp_threshold = sfp_threshold
|
self.sfp_threshold = sfp_threshold
|
||||||
self.handle_unknown_mode = handle_unknown_mode
|
self.handle_unknown_mode = handle_unknown_mode
|
||||||
self.creator_name_cache = creator_name_cache
|
self.creator_name_cache = creator_name_cache
|
||||||
|
self.add_info_in_pdf = add_info_in_pdf
|
||||||
|
self.proxies = proxies
|
||||||
|
|
||||||
|
|
||||||
if self.compress_images and Image is None:
|
if self.compress_images and Image is None:
|
||||||
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
self.logger("⚠️ Image compression disabled: Pillow library not found.")
|
||||||
@@ -256,7 +264,7 @@ class PostProcessorWorker:
|
|||||||
new_url = parsed_url._replace(netloc=new_domain).geturl()
|
new_url = parsed_url._replace(netloc=new_domain).geturl()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True) as resp:
|
with requests.head(new_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=5, allow_redirects=True, proxies=self.proxies, verify=False) as resp:
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return new_url
|
return new_url
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
@@ -331,7 +339,8 @@ class PostProcessorWorker:
|
|||||||
api_original_filename_for_size_check = file_info.get('_original_name_for_log', file_info.get('name'))
|
api_original_filename_for_size_check = file_info.get('_original_name_for_log', file_info.get('name'))
|
||||||
try:
|
try:
|
||||||
# Use a stream=True HEAD request to get headers without downloading the body
|
# Use a stream=True HEAD request to get headers without downloading the body
|
||||||
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True) as head_response:
|
with requests.head(file_url, headers=file_download_headers, timeout=15, cookies=cookies_to_use_for_file, allow_redirects=True, proxies=self.proxies, verify=False) as head_response:
|
||||||
|
|
||||||
head_response.raise_for_status()
|
head_response.raise_for_status()
|
||||||
content_length = head_response.headers.get('Content-Length')
|
content_length = head_response.headers.get('Content-Length')
|
||||||
if content_length:
|
if content_length:
|
||||||
@@ -665,7 +674,7 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
current_url_to_try = file_url
|
current_url_to_try = file_url
|
||||||
|
|
||||||
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
|
response = requests.get(current_url_to_try, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
|
||||||
|
|
||||||
if response.status_code == 403 and ('kemono.' in current_url_to_try or 'coomer.' in current_url_to_try):
|
if response.status_code == 403 and ('kemono.' in current_url_to_try or 'coomer.' in current_url_to_try):
|
||||||
self.logger(f" ⚠️ Got 403 Forbidden for '{api_original_filename}'. Attempting subdomain rotation...")
|
self.logger(f" ⚠️ Got 403 Forbidden for '{api_original_filename}'. Attempting subdomain rotation...")
|
||||||
@@ -674,8 +683,7 @@ class PostProcessorWorker:
|
|||||||
self.logger(f" Retrying with new URL: {new_url}")
|
self.logger(f" Retrying with new URL: {new_url}")
|
||||||
file_url = new_url
|
file_url = new_url
|
||||||
response.close() # Close the old response
|
response.close() # Close the old response
|
||||||
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file)
|
response = requests.get(new_url, headers=file_download_headers, timeout=(30, 300), stream=True, cookies=cookies_to_use_for_file, proxies=self.proxies, verify=False)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# --- REVISED AND MOVED SIZE CHECK LOGIC ---
|
# --- REVISED AND MOVED SIZE CHECK LOGIC ---
|
||||||
@@ -974,6 +982,92 @@ class PostProcessorWorker:
|
|||||||
else:
|
else:
|
||||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
|
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
|
||||||
|
|
||||||
|
def _get_manga_style_filename_for_post(self, post_title, original_ext):
|
||||||
|
"""Generates a filename based on manga style, using post data."""
|
||||||
|
if self.manga_filename_style == STYLE_POST_TITLE:
|
||||||
|
cleaned_post_title_base = robust_clean_name(post_title.strip() if post_title and post_title.strip() else "post")
|
||||||
|
return f"{cleaned_post_title_base}{original_ext}"
|
||||||
|
|
||||||
|
elif self.manga_filename_style == STYLE_CUSTOM:
|
||||||
|
try:
|
||||||
|
def format_date(date_str):
|
||||||
|
if not date_str or 'NoDate' in date_str:
|
||||||
|
return "NoDate"
|
||||||
|
try:
|
||||||
|
dt_obj = datetime.fromisoformat(date_str)
|
||||||
|
strftime_format = self.manga_custom_date_format.replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
|
||||||
|
return dt_obj.strftime(strftime_format)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return date_str.split('T')[0]
|
||||||
|
|
||||||
|
service = self.service.lower()
|
||||||
|
user_id = str(self.user_id)
|
||||||
|
creator_name = self.creator_name_cache.get((service, user_id), user_id)
|
||||||
|
|
||||||
|
added_date = self.post.get('added')
|
||||||
|
published_date = self.post.get('published')
|
||||||
|
edited_date = self.post.get('edited')
|
||||||
|
|
||||||
|
format_values = {
|
||||||
|
'id': str(self.post.get('id', '')),
|
||||||
|
'user': user_id,
|
||||||
|
'creator_name': creator_name,
|
||||||
|
'service': self.service,
|
||||||
|
'title': str(self.post.get('title', '')),
|
||||||
|
'name': robust_clean_name(post_title), # Use post title as a fallback 'name'
|
||||||
|
'added': format_date(added_date or published_date),
|
||||||
|
'published': format_date(published_date),
|
||||||
|
'edited': format_date(edited_date or published_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
custom_base_name = self.manga_custom_filename_format.format(**format_values)
|
||||||
|
cleaned_custom_name = robust_clean_name(custom_base_name)
|
||||||
|
|
||||||
|
return f"{cleaned_custom_name}{original_ext}"
|
||||||
|
|
||||||
|
except (KeyError, IndexError, ValueError) as e:
|
||||||
|
self.logger(f"⚠️ Custom format error for text export: {e}. Falling back to post title.")
|
||||||
|
return f"{robust_clean_name(post_title.strip() or 'untitled_post')}{original_ext}"
|
||||||
|
|
||||||
|
elif self.manga_filename_style == STYLE_DATE_POST_TITLE:
|
||||||
|
published_date_str = self.post.get('published')
|
||||||
|
added_date_str = self.post.get('added')
|
||||||
|
formatted_date_str = "nodate"
|
||||||
|
if published_date_str:
|
||||||
|
try:
|
||||||
|
formatted_date_str = published_date_str.split('T')[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif added_date_str:
|
||||||
|
try:
|
||||||
|
formatted_date_str = added_date_str.split('T')[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cleaned_post_title_for_filename = robust_clean_name(post_title.strip() or "post")
|
||||||
|
base_name_for_style = f"{formatted_date_str}_{cleaned_post_title_for_filename}"
|
||||||
|
return f"{base_name_for_style}{original_ext}"
|
||||||
|
|
||||||
|
elif self.manga_filename_style == STYLE_POST_ID:
|
||||||
|
post_id = str(self.post.get('id', 'unknown_id'))
|
||||||
|
return f"{post_id}{original_ext}"
|
||||||
|
|
||||||
|
elif self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||||
|
published_date_str = self.post.get('published') or self.post.get('added')
|
||||||
|
formatted_date_str = "nodate"
|
||||||
|
if published_date_str:
|
||||||
|
try:
|
||||||
|
formatted_date_str = published_date_str.split('T')[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Use post title as the name part, as there is no "original filename" for the text export.
|
||||||
|
cleaned_post_title_base = robust_clean_name(post_title.strip() or "untitled_post")
|
||||||
|
return f"{formatted_date_str}_{cleaned_post_title_base}{original_ext}"
|
||||||
|
|
||||||
|
# Default fallback
|
||||||
|
return f"{robust_clean_name(post_title.strip() or 'untitled_post')}{original_ext}"
|
||||||
|
|
||||||
def process(self):
|
def process(self):
|
||||||
result_tuple = (0, 0, [], [], [], None, None)
|
result_tuple = (0, 0, [], [], [], None, None)
|
||||||
try:
|
try:
|
||||||
@@ -1012,7 +1106,7 @@ class PostProcessorWorker:
|
|||||||
'Accept': 'text/css'
|
'Accept': 'text/css'
|
||||||
}
|
}
|
||||||
cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain)
|
cookies = prepare_cookies_for_request(self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger, target_domain=api_domain)
|
||||||
full_post_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
|
full_post_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies, proxies=self.proxies)
|
||||||
if full_post_data:
|
if full_post_data:
|
||||||
self.logger(" ✅ Full post data fetched successfully.")
|
self.logger(" ✅ Full post data fetched successfully.")
|
||||||
self.post = full_post_data
|
self.post = full_post_data
|
||||||
@@ -1213,13 +1307,17 @@ class PostProcessorWorker:
|
|||||||
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
if not any(d in api_domain_for_comments.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||||
self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.")
|
self.logger(f"⚠️ Unrecognized domain '{api_domain_for_comments}' for comment API. Defaulting based on service.")
|
||||||
api_domain_for_comments = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
|
api_domain_for_comments = "kemono.cr" if "kemono" in self.service.lower() else "coomer.st"
|
||||||
|
|
||||||
|
# Fetch comments (Indented correctly now)
|
||||||
comments_data = fetch_post_comments(
|
comments_data = fetch_post_comments(
|
||||||
api_domain_for_comments, self.service, self.user_id, post_id,
|
api_domain_for_comments, self.service, self.user_id, post_id,
|
||||||
headers, self.logger, self.cancellation_event, self.pause_event,
|
headers, self.logger, self.cancellation_event, self.pause_event,
|
||||||
cookies_dict=prepare_cookies_for_request(
|
cookies_dict=prepare_cookies_for_request(
|
||||||
self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger
|
self.use_cookie, self.cookie_text, self.selected_cookie_file, self.app_base_dir, self.logger
|
||||||
|
),
|
||||||
|
proxies=self.proxies
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if comments_data:
|
if comments_data:
|
||||||
self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.")
|
self.logger(f" Fetched {len(comments_data)} comments for post {post_id}.")
|
||||||
for comment_item_idx, comment_item in enumerate(comments_data):
|
for comment_item_idx, comment_item in enumerate(comments_data):
|
||||||
@@ -1247,7 +1345,7 @@ class PostProcessorWorker:
|
|||||||
self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}")
|
self.logger(f" ⚠️ Error fetching or processing comments for post {post_id}: {e_fetch_comment}")
|
||||||
except Exception as e_generic_comment:
|
except Exception as e_generic_comment:
|
||||||
self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}")
|
self.logger(f" ❌ Unexpected error during comment processing for post {post_id}: {e_generic_comment}\n{traceback.format_exc(limit=2)}")
|
||||||
self.logger(f" [Char Scope: Comments] Phase 2 Result: post_is_candidate_by_comment_char_match = {post_is_candidate_by_comment_char_match}")
|
|
||||||
else:
|
else:
|
||||||
self.logger(f" [Char Scope: Comments] Phase 2: Skipped comment check for post ID '{post_id}' because a file match already made it a candidate.")
|
self.logger(f" [Char Scope: Comments] Phase 2: Skipped comment check for post ID '{post_id}' because a file match already made it a candidate.")
|
||||||
|
|
||||||
@@ -1269,6 +1367,8 @@ class PostProcessorWorker:
|
|||||||
if self.filter_mode == 'text_only' and not self.extract_links_only:
|
if self.filter_mode == 'text_only' and not self.extract_links_only:
|
||||||
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
|
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
|
||||||
post_title_lower = post_title.lower()
|
post_title_lower = post_title.lower()
|
||||||
|
|
||||||
|
# --- Skip Words Check ---
|
||||||
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
|
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
|
||||||
for skip_word in self.skip_words_list:
|
for skip_word in self.skip_words_list:
|
||||||
if skip_word.lower() in post_title_lower:
|
if skip_word.lower() in post_title_lower:
|
||||||
@@ -1287,6 +1387,7 @@ class PostProcessorWorker:
|
|||||||
comments_data = []
|
comments_data = []
|
||||||
final_post_data = post_data
|
final_post_data = post_data
|
||||||
|
|
||||||
|
# --- Content Fetching ---
|
||||||
if self.text_only_scope == 'content' and 'content' not in final_post_data:
|
if self.text_only_scope == 'content' and 'content' not in final_post_data:
|
||||||
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
|
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
|
||||||
parsed_url = urlparse(self.api_url_input)
|
parsed_url = urlparse(self.api_url_input)
|
||||||
@@ -1304,6 +1405,8 @@ class PostProcessorWorker:
|
|||||||
api_domain = parsed_url.netloc
|
api_domain = parsed_url.netloc
|
||||||
comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event)
|
comments_data = fetch_post_comments(api_domain, self.service, self.user_id, post_id, headers, self.logger, self.cancellation_event, self.pause_event)
|
||||||
if comments_data:
|
if comments_data:
|
||||||
|
# For TXT/DOCX export, we format comments here.
|
||||||
|
# For PDF, we pass the raw list to the generator.
|
||||||
comment_texts = []
|
comment_texts = []
|
||||||
for comment in comments_data:
|
for comment in comments_data:
|
||||||
user = comment.get('commenter_name', 'Unknown User')
|
user = comment.get('commenter_name', 'Unknown User')
|
||||||
@@ -1335,23 +1438,43 @@ class PostProcessorWorker:
|
|||||||
self._emit_signal('worker_finished', result_tuple)
|
self._emit_signal('worker_finished', result_tuple)
|
||||||
return result_tuple
|
return result_tuple
|
||||||
|
|
||||||
if self.single_pdf_mode:
|
# --- Metadata Preparation ---
|
||||||
content_data = {
|
# 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,
|
'title': post_title,
|
||||||
'published': self.post.get('published') or self.post.get('added')
|
'published': self.post.get('published') or self.post.get('added'),
|
||||||
|
'service': service_str,
|
||||||
|
'user': user_id_str,
|
||||||
|
'id': post_id_str,
|
||||||
|
'tags': self.post.get('tags'),
|
||||||
|
'original_link': post_page_url,
|
||||||
|
'creator_name': creator_name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Single PDF Mode (Save Temp JSON) ---
|
||||||
|
if self.single_pdf_mode:
|
||||||
if self.text_only_scope == 'comments':
|
if self.text_only_scope == 'comments':
|
||||||
if not comments_data:
|
if not comments_data:
|
||||||
result_tuple = (0, 0, [], [], [], None, None)
|
result_tuple = (0, 0, [], [], [], None, None)
|
||||||
self._emit_signal('worker_finished', result_tuple)
|
self._emit_signal('worker_finished', result_tuple)
|
||||||
return result_tuple
|
return result_tuple
|
||||||
content_data['comments'] = comments_data
|
common_content_data['comments'] = comments_data
|
||||||
else:
|
else:
|
||||||
if not cleaned_text.strip():
|
if not cleaned_text.strip():
|
||||||
result_tuple = (0, 0, [], [], [], None, None)
|
result_tuple = (0, 0, [], [], [], None, None)
|
||||||
self._emit_signal('worker_finished', result_tuple)
|
self._emit_signal('worker_finished', result_tuple)
|
||||||
return result_tuple
|
return result_tuple
|
||||||
content_data['content'] = cleaned_text
|
common_content_data['content'] = cleaned_text
|
||||||
|
|
||||||
temp_dir = os.path.join(self.app_base_dir, "appdata")
|
temp_dir = os.path.join(self.app_base_dir, "appdata")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
@@ -1359,7 +1482,7 @@ class PostProcessorWorker:
|
|||||||
temp_filepath = os.path.join(temp_dir, temp_filename)
|
temp_filepath = os.path.join(temp_dir, temp_filename)
|
||||||
try:
|
try:
|
||||||
with open(temp_filepath, 'w', encoding='utf-8') as f:
|
with open(temp_filepath, 'w', encoding='utf-8') as f:
|
||||||
json.dump(content_data, f, indent=2)
|
json.dump(common_content_data, f, indent=2)
|
||||||
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
|
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
|
||||||
result_tuple = (0, 0, [], [], [], None, temp_filepath)
|
result_tuple = (0, 0, [], [], [], None, temp_filepath)
|
||||||
self._emit_signal('worker_finished', result_tuple)
|
self._emit_signal('worker_finished', result_tuple)
|
||||||
@@ -1369,82 +1492,67 @@ class PostProcessorWorker:
|
|||||||
result_tuple = (0, 0, [], [], [], None, None)
|
result_tuple = (0, 0, [], [], [], None, None)
|
||||||
self._emit_signal('worker_finished', result_tuple)
|
self._emit_signal('worker_finished', result_tuple)
|
||||||
return result_tuple
|
return result_tuple
|
||||||
|
|
||||||
|
# --- Individual File Mode ---
|
||||||
else:
|
else:
|
||||||
file_extension = self.text_export_format
|
file_extension = self.text_export_format
|
||||||
|
txt_filename = ""
|
||||||
|
|
||||||
|
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)
|
final_save_path = os.path.join(determined_post_save_path_for_history, txt_filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(determined_post_save_path_for_history, exist_ok=True)
|
os.makedirs(determined_post_save_path_for_history, exist_ok=True)
|
||||||
base, ext = os.path.splitext(final_save_path)
|
base, ext = os.path.splitext(final_save_path)
|
||||||
|
|
||||||
counter = 1
|
counter = 1
|
||||||
while os.path.exists(final_save_path):
|
while os.path.exists(final_save_path):
|
||||||
final_save_path = f"{base}_{counter}{ext}"
|
final_save_path = f"{base}_{counter}{ext}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
|
# --- PDF Generation ---
|
||||||
if file_extension == 'pdf':
|
if file_extension == 'pdf':
|
||||||
if FPDF:
|
# Font setup
|
||||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
|
||||||
pdf = PDF()
|
|
||||||
base_path = self.project_root_dir
|
|
||||||
font_path = ""
|
font_path = ""
|
||||||
bold_font_path = ""
|
if self.project_root_dir:
|
||||||
|
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||||
if base_path:
|
|
||||||
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
|
||||||
bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
|
||||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
|
||||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
|
||||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
|
||||||
default_font_family = 'DejaVu'
|
|
||||||
except Exception as font_error:
|
|
||||||
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
|
||||||
default_font_family = 'Arial'
|
|
||||||
|
|
||||||
pdf.add_page()
|
|
||||||
pdf.set_font(default_font_family, 'B', 16)
|
|
||||||
pdf.multi_cell(0, 10, post_title)
|
|
||||||
pdf.ln(10)
|
|
||||||
|
|
||||||
|
# Add content specific fields for the generator
|
||||||
if self.text_only_scope == 'comments':
|
if self.text_only_scope == 'comments':
|
||||||
if not comments_data:
|
common_content_data['comments_list_for_pdf'] = 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:
|
else:
|
||||||
pdf.set_font(default_font_family, '', 12)
|
common_content_data['content_text_for_pdf'] = cleaned_text
|
||||||
pdf.multi_cell(0, 7, cleaned_text)
|
|
||||||
|
|
||||||
pdf.output(final_save_path)
|
# Call the centralized function
|
||||||
else:
|
success = create_individual_pdf(
|
||||||
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
|
post_data=common_content_data,
|
||||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
output_filename=final_save_path,
|
||||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
font_path=font_path,
|
||||||
|
add_info_page=self.add_info_in_pdf, # <--- NEW PARAMETER
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise Exception("PDF generation failed (check logs)")
|
||||||
|
|
||||||
|
# --- DOCX Generation ---
|
||||||
elif file_extension == 'docx':
|
elif file_extension == 'docx':
|
||||||
if Document:
|
if Document:
|
||||||
self.logger(f" Converting to DOCX...")
|
self.logger(f" Converting to DOCX...")
|
||||||
document = Document()
|
document = Document()
|
||||||
|
# Add simple header info if desired, or keep pure text
|
||||||
|
if self.add_info_in_pdf:
|
||||||
|
document.add_heading(post_title, 0)
|
||||||
|
document.add_paragraph(f"Date: {common_content_data['published']}")
|
||||||
|
document.add_paragraph(f"Creator: {common_content_data['creator_name']}")
|
||||||
|
document.add_paragraph(f"URL: {common_content_data['original_link']}")
|
||||||
|
document.add_page_break()
|
||||||
|
|
||||||
document.add_paragraph(cleaned_text)
|
document.add_paragraph(cleaned_text)
|
||||||
document.save(final_save_path)
|
document.save(final_save_path)
|
||||||
else:
|
else:
|
||||||
@@ -1452,9 +1560,20 @@ class PostProcessorWorker:
|
|||||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||||
|
|
||||||
else: # TXT file
|
# --- TXT Generation ---
|
||||||
|
else:
|
||||||
|
content_to_write = cleaned_text
|
||||||
|
# Optional: Add simple text header if "Add Info" is checked
|
||||||
|
if self.add_info_in_pdf:
|
||||||
|
header = (f"Title: {post_title}\n"
|
||||||
|
f"Date: {common_content_data['published']}\n"
|
||||||
|
f"Creator: {common_content_data['creator_name']}\n"
|
||||||
|
f"URL: {common_content_data['original_link']}\n"
|
||||||
|
f"{'-'*40}\n\n")
|
||||||
|
content_to_write = header + cleaned_text
|
||||||
|
|
||||||
with open(final_save_path, 'w', encoding='utf-8') as f:
|
with open(final_save_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(cleaned_text)
|
f.write(content_to_write)
|
||||||
|
|
||||||
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
|
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
|
||||||
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
|
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
|
||||||
@@ -1467,6 +1586,7 @@ class PostProcessorWorker:
|
|||||||
self._emit_signal('worker_finished', result_tuple)
|
self._emit_signal('worker_finished', result_tuple)
|
||||||
return result_tuple
|
return result_tuple
|
||||||
|
|
||||||
|
|
||||||
if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match:
|
if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match:
|
||||||
self.logger(f" -> Skip Post (Renaming Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.")
|
self.logger(f" -> Skip Post (Renaming Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.")
|
||||||
self._emit_signal('missed_character_post', post_title, "Renaming Mode: No title match for character filter (Title/Both scope)")
|
self._emit_signal('missed_character_post', post_title, "Renaming Mode: No title match for character filter (Title/Both scope)")
|
||||||
@@ -1480,12 +1600,11 @@ class PostProcessorWorker:
|
|||||||
|
|
||||||
should_create_post_subfolder = self.use_post_subfolders
|
should_create_post_subfolder = self.use_post_subfolders
|
||||||
|
|
||||||
if (not self.use_post_subfolders and self.use_subfolders and
|
if (not self.use_post_subfolders and
|
||||||
self.sfp_threshold is not None and num_potential_files_in_post >= self.sfp_threshold):
|
self.sfp_threshold is not None and num_potential_files_in_post >= self.sfp_threshold):
|
||||||
|
|
||||||
self.logger(f" ℹ️ Post has {num_potential_files_in_post} files (≥{self.sfp_threshold}). Activating Subfolder per Post via [sfp] command.")
|
self.logger(f" ℹ️ Post has {num_potential_files_in_post} files (≥{self.sfp_threshold}). Activating Subfolder per Post via [sfp] command.")
|
||||||
should_create_post_subfolder = True
|
should_create_post_subfolder = True
|
||||||
|
|
||||||
base_folder_names_for_post_content = []
|
base_folder_names_for_post_content = []
|
||||||
determined_post_save_path_for_history = self.override_output_dir if self.override_output_dir else self.download_root
|
determined_post_save_path_for_history = self.override_output_dir if self.override_output_dir else self.download_root
|
||||||
if not self.extract_links_only and self.use_subfolders:
|
if not self.extract_links_only and self.use_subfolders:
|
||||||
@@ -2212,9 +2331,10 @@ class DownloadThread(QThread):
|
|||||||
manga_custom_filename_format="{published} {title}",
|
manga_custom_filename_format="{published} {title}",
|
||||||
manga_custom_date_format="YYYY-MM-DD" ,
|
manga_custom_date_format="YYYY-MM-DD" ,
|
||||||
sfp_threshold=None,
|
sfp_threshold=None,
|
||||||
creator_name_cache=None
|
creator_name_cache=None,
|
||||||
|
proxies=None
|
||||||
):
|
):
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.api_url_input = api_url_input
|
self.api_url_input = api_url_input
|
||||||
self.output_dir = output_dir
|
self.output_dir = output_dir
|
||||||
@@ -2289,6 +2409,7 @@ class DownloadThread(QThread):
|
|||||||
self.domain_override = domain_override
|
self.domain_override = domain_override
|
||||||
self.sfp_threshold = sfp_threshold
|
self.sfp_threshold = sfp_threshold
|
||||||
self.creator_name_cache = creator_name_cache
|
self.creator_name_cache = creator_name_cache
|
||||||
|
self.proxies = proxies
|
||||||
|
|
||||||
if self.compress_images and Image is None:
|
if self.compress_images and Image is None:
|
||||||
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
|
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
|
||||||
@@ -2322,6 +2443,7 @@ class DownloadThread(QThread):
|
|||||||
|
|
||||||
self.logger(" Starting post fetch (single-threaded download process)...")
|
self.logger(" Starting post fetch (single-threaded download process)...")
|
||||||
|
|
||||||
|
# --- FIX: Removed duplicate proxies argument here ---
|
||||||
post_generator = download_from_api(
|
post_generator = download_from_api(
|
||||||
self.api_url_input,
|
self.api_url_input,
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
@@ -2336,9 +2458,11 @@ class DownloadThread(QThread):
|
|||||||
app_base_dir=self.app_base_dir,
|
app_base_dir=self.app_base_dir,
|
||||||
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
|
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
|
||||||
processed_post_ids=self.processed_post_ids_set,
|
processed_post_ids=self.processed_post_ids_set,
|
||||||
fetch_all_first=self.fetch_first
|
fetch_all_first=self.fetch_first,
|
||||||
|
proxies=self.proxies
|
||||||
)
|
)
|
||||||
|
|
||||||
|
processed_count_for_delay = 0
|
||||||
for posts_batch_data in post_generator:
|
for posts_batch_data in post_generator:
|
||||||
if self.isInterruptionRequested():
|
if self.isInterruptionRequested():
|
||||||
was_process_cancelled = True
|
was_process_cancelled = True
|
||||||
@@ -2349,6 +2473,11 @@ class DownloadThread(QThread):
|
|||||||
was_process_cancelled = True
|
was_process_cancelled = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
processed_count_for_delay += 1
|
||||||
|
if processed_count_for_delay > 0 and processed_count_for_delay % 50 == 0:
|
||||||
|
self.logger(" ⏳ Safety Pause: Waiting 10 seconds to respect server rate limits...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
worker_args = {
|
worker_args = {
|
||||||
'post_data': individual_post_data,
|
'post_data': individual_post_data,
|
||||||
'emitter': worker_signals_obj,
|
'emitter': worker_signals_obj,
|
||||||
@@ -2417,7 +2546,8 @@ class DownloadThread(QThread):
|
|||||||
'archive_only_mode': self.archive_only_mode,
|
'archive_only_mode': self.archive_only_mode,
|
||||||
'manga_custom_filename_format': self.manga_custom_filename_format,
|
'manga_custom_filename_format': self.manga_custom_filename_format,
|
||||||
'manga_custom_date_format': self.manga_custom_date_format,
|
'manga_custom_date_format': self.manga_custom_date_format,
|
||||||
'sfp_threshold': self.sfp_threshold
|
'sfp_threshold': self.sfp_threshold,
|
||||||
|
'proxies': self.proxies
|
||||||
}
|
}
|
||||||
|
|
||||||
post_processing_worker = PostProcessorWorker(**worker_args)
|
post_processing_worker = PostProcessorWorker(**worker_args)
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ class AllcomicDownloadThread(QThread):
|
|||||||
finished_signal = pyqtSignal(int, int, bool)
|
finished_signal = pyqtSignal(int, int, bool)
|
||||||
overall_progress_signal = pyqtSignal(int, int)
|
overall_progress_signal = pyqtSignal(int, int)
|
||||||
|
|
||||||
def __init__(self, url, output_dir, parent=None):
|
# 1. Update __init__ to accept proxies
|
||||||
|
def __init__(self, url, output_dir, parent=None, proxies=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.comic_url = url
|
self.comic_url = url
|
||||||
self.output_dir = output_dir
|
self.output_dir = output_dir
|
||||||
self.is_cancelled = False
|
self.is_cancelled = False
|
||||||
self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
|
self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
|
||||||
|
self.proxies = proxies # Store the proxies
|
||||||
|
|
||||||
def _check_pause(self):
|
def _check_pause(self):
|
||||||
if self.is_cancelled: return True
|
if self.is_cancelled: return True
|
||||||
@@ -40,13 +42,19 @@ class AllcomicDownloadThread(QThread):
|
|||||||
grand_total_dl = 0
|
grand_total_dl = 0
|
||||||
grand_total_skip = 0
|
grand_total_skip = 0
|
||||||
|
|
||||||
# Create the scraper session ONCE for the entire job
|
if self.proxies:
|
||||||
scraper = cloudscraper.create_scraper(
|
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
|
||||||
browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True}
|
else:
|
||||||
)
|
self.progress_signal.emit(" 🌍 Network: Direct Connection (No Proxy)")
|
||||||
|
|
||||||
# Pass the scraper to the function
|
scraper = requests.Session()
|
||||||
chapters_to_download = allcomic_get_list(scraper, self.comic_url, self.progress_signal.emit)
|
scraper.headers.update({
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Pass self.proxies to get_chapter_list
|
||||||
|
chapters_to_download = allcomic_get_list(scraper, self.comic_url, self.progress_signal.emit, proxies=self.proxies)
|
||||||
|
|
||||||
if not chapters_to_download:
|
if not chapters_to_download:
|
||||||
chapters_to_download = [self.comic_url]
|
chapters_to_download = [self.comic_url]
|
||||||
@@ -57,8 +65,9 @@ class AllcomicDownloadThread(QThread):
|
|||||||
if self._check_pause(): break
|
if self._check_pause(): break
|
||||||
|
|
||||||
self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --")
|
self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --")
|
||||||
# Pass the scraper to the function
|
|
||||||
comic_title, chapter_title, image_urls = allcomic_fetch_data(scraper, chapter_url, self.progress_signal.emit)
|
# 3. Pass self.proxies to fetch_chapter_data
|
||||||
|
comic_title, chapter_title, image_urls = allcomic_fetch_data(scraper, chapter_url, self.progress_signal.emit, proxies=self.proxies)
|
||||||
|
|
||||||
if not image_urls:
|
if not image_urls:
|
||||||
self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
|
self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
|
||||||
@@ -80,6 +89,9 @@ class AllcomicDownloadThread(QThread):
|
|||||||
self.overall_progress_signal.emit(total_files_in_chapter, 0)
|
self.overall_progress_signal.emit(total_files_in_chapter, 0)
|
||||||
headers = {'Referer': chapter_url}
|
headers = {'Referer': chapter_url}
|
||||||
|
|
||||||
|
# 4. Define smart timeout for images
|
||||||
|
img_timeout = (30, 120) if self.proxies else 60
|
||||||
|
|
||||||
for i, img_url in enumerate(image_urls):
|
for i, img_url in enumerate(image_urls):
|
||||||
if self._check_pause(): break
|
if self._check_pause(): break
|
||||||
|
|
||||||
@@ -97,8 +109,9 @@ class AllcomicDownloadThread(QThread):
|
|||||||
if self._check_pause(): break
|
if self._check_pause(): break
|
||||||
try:
|
try:
|
||||||
self.progress_signal.emit(f" Downloading ({i+1}/{total_files_in_chapter}): '{filename}' (Attempt {attempt + 1})...")
|
self.progress_signal.emit(f" Downloading ({i+1}/{total_files_in_chapter}): '{filename}' (Attempt {attempt + 1})...")
|
||||||
# Use the persistent scraper object
|
|
||||||
response = scraper.get(img_url, stream=True, headers=headers, timeout=60)
|
# 5. Use proxies, verify=False, and new timeout
|
||||||
|
response = scraper.get(img_url, stream=True, headers=headers, timeout=img_timeout, proxies=self.proxies, verify=False)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
@@ -125,7 +138,7 @@ class AllcomicDownloadThread(QThread):
|
|||||||
grand_total_skip += 1
|
grand_total_skip += 1
|
||||||
|
|
||||||
self.overall_progress_signal.emit(total_files_in_chapter, i + 1)
|
self.overall_progress_signal.emit(total_files_in_chapter, i + 1)
|
||||||
time.sleep(0.5) # Increased delay between images for this site
|
time.sleep(0.5)
|
||||||
|
|
||||||
if self._check_pause(): break
|
if self._check_pause(): break
|
||||||
|
|
||||||
|
|||||||
212
src/ui/classes/deviantart_downloader_thread.py
Normal file
212
src/ui/classes/deviantart_downloader_thread.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import random # Needed for random delays
|
||||||
|
from datetime import datetime
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 1. Accept proxies in init
|
||||||
|
def __init__(self, url, output_dir, pause_event, cancellation_event, parent=None, proxies=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.url = url
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.pause_event = pause_event
|
||||||
|
self.cancellation_event = cancellation_event
|
||||||
|
self.proxies = proxies # Store proxies
|
||||||
|
|
||||||
|
self.parent_app = parent
|
||||||
|
self.download_count = 0
|
||||||
|
self.skip_count = 0
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.client = DeviantArtClient(logger_func=self.progress_signal.emit, proxies=self.proxies)
|
||||||
|
|
||||||
|
if self.proxies:
|
||||||
|
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
|
||||||
|
else:
|
||||||
|
self.progress_signal.emit(" 🌍 Network: Direct Connection")
|
||||||
|
|
||||||
|
self.progress_signal.emit("=" * 40)
|
||||||
|
self.progress_signal.emit(f"🚀 Starting DeviantArt download for: {self.url}")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for deviation in results:
|
||||||
|
if self._check_pause_cancel(): break
|
||||||
|
self._process_deviation_task(deviation, base_folder)
|
||||||
|
|
||||||
|
# 4. FIX 429: Add a small random delay between items
|
||||||
|
# This prevents hammering the API 24 times in a single second.
|
||||||
|
time.sleep(random.uniform(0.5, 1.2))
|
||||||
|
|
||||||
|
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:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# 5. Determine smart timeout for files
|
||||||
|
timeout_val = (30, 120) if self.proxies else 30
|
||||||
|
|
||||||
|
# 6. Use proxies and verify=False
|
||||||
|
with requests.get(file_url, stream=True, timeout=timeout_val, proxies=self.proxies, verify=False) 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,8 @@ from .rule34video_downloader_thread import Rule34VideoDownloadThread
|
|||||||
from .saint2_downloader_thread import Saint2DownloadThread
|
from .saint2_downloader_thread import Saint2DownloadThread
|
||||||
from .simp_city_downloader_thread import SimpCityDownloadThread
|
from .simp_city_downloader_thread import SimpCityDownloadThread
|
||||||
from .toonily_downloader_thread import ToonilyDownloadThread
|
from .toonily_downloader_thread import ToonilyDownloadThread
|
||||||
|
from .deviantart_downloader_thread import DeviantArtDownloadThread
|
||||||
|
from .hentaifox_downloader_thread import HentaiFoxDownloadThread
|
||||||
|
|
||||||
def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
|
def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
|
||||||
"""
|
"""
|
||||||
@@ -175,6 +176,28 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
|
|||||||
# id1 contains the full URL or album ID from extract_post_info
|
# id1 contains the full URL or album ID from extract_post_info
|
||||||
return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app)
|
return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app)
|
||||||
|
|
||||||
|
# Handler for DeviantArt
|
||||||
|
if service == 'deviantart':
|
||||||
|
main_app.log_signal.emit(f"ℹ️ DeviantArt URL detected. Starting dedicated downloader.")
|
||||||
|
return DeviantArtDownloadThread(
|
||||||
|
url=api_url,
|
||||||
|
output_dir=effective_output_dir_for_run,
|
||||||
|
pause_event=main_app.pause_event,
|
||||||
|
cancellation_event=main_app.cancellation_event,
|
||||||
|
parent=main_app
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handler for HentaiFox (New)
|
||||||
|
if 'hentaifox.com' in api_url or service == 'hentaifox':
|
||||||
|
main_app.log_signal.emit("🦊 HentaiFox URL detected.")
|
||||||
|
return HentaiFoxDownloadThread(
|
||||||
|
url_or_id=api_url,
|
||||||
|
output_dir=effective_output_dir_for_run,
|
||||||
|
parent=main_app
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
# --- Fallback ---
|
# --- Fallback ---
|
||||||
# If no specific handler matched based on service name or URL pattern, return None.
|
# If no specific handler matched based on service name or URL pattern, return None.
|
||||||
# This signals main_window.py to use the generic BackendDownloadThread/PostProcessorWorker
|
# This signals main_window.py to use the generic BackendDownloadThread/PostProcessorWorker
|
||||||
|
|||||||
136
src/ui/classes/hentaifox_downloader_thread.py
Normal file
136
src/ui/classes/hentaifox_downloader_thread.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from PyQt5.QtCore import QThread, pyqtSignal
|
||||||
|
from ...core.hentaifox_client import get_gallery_metadata, get_image_link_for_page, get_gallery_id
|
||||||
|
from ...utils.file_utils import clean_folder_name
|
||||||
|
|
||||||
|
class HentaiFoxDownloadThread(QThread):
|
||||||
|
progress_signal = pyqtSignal(str) # Log messages
|
||||||
|
file_progress_signal = pyqtSignal(str, object) # filename, (current_bytes, total_bytes)
|
||||||
|
# finished_signal: (downloaded_count, skipped_count, was_cancelled, kept_files_list)
|
||||||
|
finished_signal = pyqtSignal(int, int, bool, list)
|
||||||
|
|
||||||
|
def __init__(self, url_or_id, output_dir, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.gallery_id = get_gallery_id(url_or_id)
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.is_running = True
|
||||||
|
self.downloaded_count = 0
|
||||||
|
self.skipped_count = 0
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
self.progress_signal.emit(f"🔍 [HentaiFox] Fetching metadata for ID: {self.gallery_id}...")
|
||||||
|
|
||||||
|
# 1. Get Info
|
||||||
|
try:
|
||||||
|
data = get_gallery_metadata(self.gallery_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.progress_signal.emit(f"❌ [HentaiFox] Failed to fetch metadata: {e}")
|
||||||
|
self.finished_signal.emit(0, 0, False, [])
|
||||||
|
return
|
||||||
|
|
||||||
|
title = clean_folder_name(data['title'])
|
||||||
|
total_pages = data['total_pages']
|
||||||
|
|
||||||
|
# 2. Setup Folder
|
||||||
|
save_folder = os.path.join(self.output_dir, f"[{self.gallery_id}] {title}")
|
||||||
|
os.makedirs(save_folder, exist_ok=True)
|
||||||
|
|
||||||
|
self.progress_signal.emit(f"📂 Saving to: {save_folder}")
|
||||||
|
self.progress_signal.emit(f"📄 Found {total_pages} pages. Starting download...")
|
||||||
|
|
||||||
|
# 3. Iterate and Download
|
||||||
|
for i in range(1, total_pages + 1):
|
||||||
|
if not self.is_running:
|
||||||
|
self.progress_signal.emit("🛑 Download cancelled by user.")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fetch image link for this specific page
|
||||||
|
try:
|
||||||
|
img_url = get_image_link_for_page(self.gallery_id, i)
|
||||||
|
|
||||||
|
if img_url:
|
||||||
|
ext = img_url.split('.')[-1]
|
||||||
|
filename = f"{i:03d}.{ext}"
|
||||||
|
filepath = os.path.join(save_folder, filename)
|
||||||
|
|
||||||
|
# Check if exists
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
self.progress_signal.emit(f"⚠️ [{i}/{total_pages}] Skipped (Exists): {filename}")
|
||||||
|
self.skipped_count += 1
|
||||||
|
else:
|
||||||
|
self.progress_signal.emit(f"⬇️ [{i}/{total_pages}] Downloading: {filename}")
|
||||||
|
|
||||||
|
# CALL NEW DOWNLOAD FUNCTION
|
||||||
|
success = self.download_image_with_progress(img_url, filepath, filename)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.progress_signal.emit(f"✅ [{i}/{total_pages}] Finished: {filename}")
|
||||||
|
self.downloaded_count += 1
|
||||||
|
else:
|
||||||
|
self.progress_signal.emit(f"❌ [{i}/{total_pages}] Failed: {filename}")
|
||||||
|
self.skipped_count += 1
|
||||||
|
else:
|
||||||
|
self.progress_signal.emit(f"❌ [{i}/{total_pages}] Error: No image link found.")
|
||||||
|
self.skipped_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.progress_signal.emit(f"❌ [{i}/{total_pages}] Exception: {e}")
|
||||||
|
self.skipped_count += 1
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# 4. Final Summary
|
||||||
|
summary = (
|
||||||
|
f"\n🏁 [HentaiFox] Task Complete!\n"
|
||||||
|
f" - Total: {total_pages}\n"
|
||||||
|
f" - Downloaded: {self.downloaded_count}\n"
|
||||||
|
f" - Skipped: {self.skipped_count}\n"
|
||||||
|
)
|
||||||
|
self.progress_signal.emit(summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.progress_signal.emit(f"❌ Critical Error: {str(e)}")
|
||||||
|
|
||||||
|
self.finished_signal.emit(self.downloaded_count, self.skipped_count, not self.is_running, [])
|
||||||
|
|
||||||
|
def download_image_with_progress(self, url, path, filename):
|
||||||
|
"""Downloads file while emitting byte-level progress signals."""
|
||||||
|
headers = {
|
||||||
|
"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",
|
||||||
|
"Referer": "https://hentaifox.com/"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# stream=True is required to get size before downloading body
|
||||||
|
r = requests.get(url, headers=headers, stream=True, timeout=20)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get Total Size (in bytes)
|
||||||
|
total_size = int(r.headers.get('content-length', 0))
|
||||||
|
downloaded_size = 0
|
||||||
|
|
||||||
|
chunk_size = 1024 # 1KB chunks
|
||||||
|
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size):
|
||||||
|
if not self.is_running:
|
||||||
|
r.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
|
||||||
|
self.file_progress_signal.emit(filename, (downloaded_size, total_size))
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Download Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.is_running = False
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import cloudscraper
|
import requests
|
||||||
from PyQt5.QtCore import QThread, pyqtSignal
|
from PyQt5.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from ...utils.file_utils import clean_folder_name
|
from ...utils.file_utils import clean_folder_name
|
||||||
@@ -17,68 +17,78 @@ class NhentaiDownloadThread(QThread):
|
|||||||
|
|
||||||
EXTENSION_MAP = {'j': 'jpg', 'p': 'png', 'g': 'gif', 'w': 'webp' }
|
EXTENSION_MAP = {'j': 'jpg', 'p': 'png', 'g': 'gif', 'w': 'webp' }
|
||||||
|
|
||||||
|
# 1. Update init to initialize self.proxies
|
||||||
def __init__(self, gallery_data, output_dir, parent=None):
|
def __init__(self, gallery_data, output_dir, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.gallery_data = gallery_data
|
self.gallery_data = gallery_data
|
||||||
self.output_dir = output_dir
|
self.output_dir = output_dir
|
||||||
self.is_cancelled = False
|
self.is_cancelled = False
|
||||||
|
self.proxies = None # Placeholder, will be injected by main_window
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
# 2. Log Proxy Usage
|
||||||
|
if self.proxies:
|
||||||
|
self.progress_signal.emit(f" 🌍 Network: Using Proxy {self.proxies}")
|
||||||
|
else:
|
||||||
|
self.progress_signal.emit(" 🌍 Network: Direct Connection (No Proxy)")
|
||||||
|
|
||||||
title = self.gallery_data.get("title", {}).get("english", f"gallery_{self.gallery_data.get('id')}")
|
title = self.gallery_data.get("title", {}).get("english", f"gallery_{self.gallery_data.get('id')}")
|
||||||
gallery_id = self.gallery_data.get("id")
|
gallery_id = self.gallery_data.get("id")
|
||||||
media_id = self.gallery_data.get("media_id")
|
media_id = self.gallery_data.get("media_id")
|
||||||
pages_info = self.gallery_data.get("pages", [])
|
pages_info = self.gallery_data.get("pages", [])
|
||||||
|
|
||||||
folder_name = clean_folder_name(title)
|
folder_name = clean_folder_name(title)
|
||||||
gallery_path = os.path.join(self.output_dir, folder_name)
|
save_path = os.path.join(self.output_dir, folder_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(gallery_path, exist_ok=True)
|
os.makedirs(save_path, exist_ok=True)
|
||||||
except OSError as e:
|
self.progress_signal.emit(f" Saving to: {folder_name}")
|
||||||
self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
|
except Exception as e:
|
||||||
|
self.progress_signal.emit(f" ❌ Error creating directory: {e}")
|
||||||
self.finished_signal.emit(0, len(pages_info), False)
|
self.finished_signal.emit(0, len(pages_info), False)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.progress_signal.emit(f"⬇️ Downloading '{title}' to folder '{folder_name}'...")
|
|
||||||
|
|
||||||
scraper = cloudscraper.create_scraper()
|
|
||||||
download_count = 0
|
download_count = 0
|
||||||
skip_count = 0
|
skip_count = 0
|
||||||
|
total_pages = len(pages_info)
|
||||||
|
|
||||||
|
# 3. Use requests.Session instead of cloudscraper
|
||||||
|
scraper = requests.Session()
|
||||||
|
|
||||||
|
# 4. Smart timeout logic
|
||||||
|
img_timeout = (30, 120) if self.proxies else 60
|
||||||
|
|
||||||
for i, page_data in enumerate(pages_info):
|
for i, page_data in enumerate(pages_info):
|
||||||
if self.is_cancelled:
|
if self.is_cancelled: break
|
||||||
break
|
|
||||||
|
|
||||||
page_num = i + 1
|
file_ext = self.EXTENSION_MAP.get(page_data.get('t'), 'jpg')
|
||||||
|
local_filename = f"{i+1:03d}.{file_ext}"
|
||||||
ext_char = page_data.get('t', 'j')
|
filepath = os.path.join(save_path, local_filename)
|
||||||
extension = self.EXTENSION_MAP.get(ext_char, 'jpg')
|
|
||||||
|
|
||||||
relative_path = f"/galleries/{media_id}/{page_num}.{extension}"
|
|
||||||
|
|
||||||
local_filename = f"{page_num:03d}.{extension}"
|
|
||||||
filepath = os.path.join(gallery_path, local_filename)
|
|
||||||
|
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
self.progress_signal.emit(f" -> Skip (Exists): {local_filename}")
|
self.progress_signal.emit(f" Skipping {local_filename} (already exists).")
|
||||||
skip_count += 1
|
skip_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
download_successful = False
|
download_successful = False
|
||||||
for server in self.IMAGE_SERVERS:
|
|
||||||
if self.is_cancelled:
|
|
||||||
break
|
|
||||||
|
|
||||||
full_url = f"{server}{relative_path}"
|
# Try servers until one works
|
||||||
|
for server in self.IMAGE_SERVERS:
|
||||||
|
if self.is_cancelled: break
|
||||||
|
|
||||||
|
# Construct URL: server/galleries/media_id/page_num.ext
|
||||||
|
full_url = f"{server}/galleries/{media_id}/{i+1}.{file_ext}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.progress_signal.emit(f" Downloading page {page_num}/{len(pages_info)} from {server} ...")
|
self.progress_signal.emit(f" Downloading page {i+1}/{total_pages}...")
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
'Referer': f'https://nhentai.net/g/{gallery_id}/'
|
'Referer': f'https://nhentai.net/g/{gallery_id}/'
|
||||||
}
|
}
|
||||||
|
|
||||||
response = scraper.get(full_url, headers=headers, timeout=60, stream=True)
|
# 5. Add proxies, verify=False, and timeout
|
||||||
|
response = scraper.get(full_url, headers=headers, timeout=img_timeout, stream=True, proxies=self.proxies, verify=False)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, 'wb') as f:
|
||||||
@@ -86,12 +96,14 @@ class NhentaiDownloadThread(QThread):
|
|||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
download_count += 1
|
download_count += 1
|
||||||
download_successful = True
|
download_successful = True
|
||||||
break
|
break # Stop trying servers
|
||||||
else:
|
else:
|
||||||
self.progress_signal.emit(f" -> {server} returned status {response.status_code}. Trying next server...")
|
# self.progress_signal.emit(f" -> {server} returned status {response.status_code}...")
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.progress_signal.emit(f" -> {server} failed to connect or timed out: {e}. Trying next server...")
|
# self.progress_signal.emit(f" -> {server} failed: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
if not download_successful:
|
if not download_successful:
|
||||||
self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.")
|
self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.")
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class SimpCityDownloadThread(QThread):
|
|||||||
self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
|
self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
|
||||||
self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
|
self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
|
||||||
self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
|
self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
|
||||||
|
self.should_dl_images = self.parent_app.simpcity_dl_images_cb.isChecked()
|
||||||
self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
|
self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
|
||||||
self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
|
self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
|
||||||
|
|
||||||
@@ -288,8 +289,10 @@ class SimpCityDownloadThread(QThread):
|
|||||||
enriched_jobs = self._get_enriched_jobs(jobs)
|
enriched_jobs = self._get_enriched_jobs(jobs)
|
||||||
if enriched_jobs:
|
if enriched_jobs:
|
||||||
for job in enriched_jobs:
|
for job in enriched_jobs:
|
||||||
if job['type'] == 'image': self.image_queue.put(job)
|
if job['type'] == 'image':
|
||||||
|
if self.should_dl_images: self.image_queue.put(job)
|
||||||
else: self.service_queue.put(job)
|
else: self.service_queue.put(job)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
|
base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
|
||||||
page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
|
page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
|
||||||
@@ -347,11 +350,14 @@ class SimpCityDownloadThread(QThread):
|
|||||||
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
|
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
|
||||||
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
|
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
|
||||||
end_of_thread = True
|
end_of_thread = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for job in enriched_jobs:
|
for job in enriched_jobs:
|
||||||
self.processed_job_urls.add(job.get('url'))
|
self.processed_job_urls.add(job.get('url'))
|
||||||
if job['type'] == 'image': self.image_queue.put(job)
|
if job['type'] == 'image':
|
||||||
|
if self.should_dl_images: self.image_queue.put(job)
|
||||||
else: self.service_queue.put(job)
|
else: self.service_queue.put(job)
|
||||||
|
|
||||||
page_fetch_successful = True; break
|
page_fetch_successful = True; break
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
if e.response.status_code in [403, 404]:
|
if e.response.status_code in [403, 404]:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from PyQt5.QtCore import Qt
|
|||||||
class CustomFilenameDialog(QDialog):
|
class CustomFilenameDialog(QDialog):
|
||||||
"""A dialog for creating a custom filename format string."""
|
"""A dialog for creating a custom filename format string."""
|
||||||
|
|
||||||
# --- REPLACE THE 'AVAILABLE_KEYS' LIST WITH THIS DICTIONARY ---
|
|
||||||
DISPLAY_KEY_MAP = {
|
DISPLAY_KEY_MAP = {
|
||||||
"PostID": "id",
|
"PostID": "id",
|
||||||
"CreatorName": "creator_name",
|
"CreatorName": "creator_name",
|
||||||
@@ -19,7 +18,10 @@ class CustomFilenameDialog(QDialog):
|
|||||||
"name": "name"
|
"name": "name"
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, current_format, current_date_format, parent=None):
|
# STRICT LIST: Only these three will be clickable for DeviantArt
|
||||||
|
DA_ALLOWED_KEYS = ["creator_name", "title", "published"]
|
||||||
|
|
||||||
|
def __init__(self, current_format, current_date_format, parent=None, is_deviantart=False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Custom Filename Format")
|
self.setWindowTitle("Custom Filename Format")
|
||||||
self.setMinimumWidth(500)
|
self.setMinimumWidth(500)
|
||||||
@@ -31,9 +33,11 @@ class CustomFilenameDialog(QDialog):
|
|||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
# --- Description ---
|
# --- Description ---
|
||||||
description_label = QLabel(
|
desc_text = "Create a filename format using placeholders. The date/time values will be automatically formatted."
|
||||||
"Create a filename format using placeholders. The date/time values for 'added', 'published', and 'edited' will be automatically shortened to your specified format."
|
if is_deviantart:
|
||||||
)
|
desc_text += "\n\n(DeviantArt Mode: Only Creator Name, Title, and Upload Date are available. Other buttons are disabled.)"
|
||||||
|
|
||||||
|
description_label = QLabel(desc_text)
|
||||||
description_label.setWordWrap(True)
|
description_label.setWordWrap(True)
|
||||||
layout.addWidget(description_label)
|
layout.addWidget(description_label)
|
||||||
|
|
||||||
@@ -42,15 +46,20 @@ class CustomFilenameDialog(QDialog):
|
|||||||
layout.addWidget(format_label)
|
layout.addWidget(format_label)
|
||||||
self.format_input = QLineEdit(self)
|
self.format_input = QLineEdit(self)
|
||||||
self.format_input.setText(self.current_format)
|
self.format_input.setText(self.current_format)
|
||||||
|
|
||||||
|
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)
|
layout.addWidget(self.format_input)
|
||||||
|
|
||||||
# --- Date Format Input ---
|
# --- Date Format Input ---
|
||||||
date_format_label = QLabel("Date Format (for {added}, {published}, {edited}):")
|
date_format_label = QLabel("Date Format (for {published}):")
|
||||||
layout.addWidget(date_format_label)
|
layout.addWidget(date_format_label)
|
||||||
self.date_format_input = QLineEdit(self)
|
self.date_format_input = QLineEdit(self)
|
||||||
self.date_format_input.setText(self.current_date_format)
|
self.date_format_input.setText(self.current_date_format)
|
||||||
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD or DD-MM-YYYY")
|
self.date_format_input.setPlaceholderText("e.g., YYYY-MM-DD")
|
||||||
layout.addWidget(self.date_format_input)
|
layout.addWidget(self.date_format_input)
|
||||||
|
|
||||||
# --- Available Keys Display ---
|
# --- Available Keys Display ---
|
||||||
@@ -62,7 +71,20 @@ class CustomFilenameDialog(QDialog):
|
|||||||
|
|
||||||
for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
|
for display_key, internal_key in self.DISPLAY_KEY_MAP.items():
|
||||||
key_button = QPushButton(f"{{{display_key}}}")
|
key_button = QPushButton(f"{{{display_key}}}")
|
||||||
# Use a lambda to pass the correct internal key when the button is clicked
|
|
||||||
|
# --- DeviantArt Logic ---
|
||||||
|
if is_deviantart:
|
||||||
|
if internal_key in self.DA_ALLOWED_KEYS:
|
||||||
|
# Active buttons: Bold text, enabled
|
||||||
|
key_button.setStyleSheet("font-weight: bold; color: black;")
|
||||||
|
key_button.setEnabled(True)
|
||||||
|
else:
|
||||||
|
# Inactive buttons: Disabled (Cannot be clicked)
|
||||||
|
key_button.setEnabled(False)
|
||||||
|
key_button.setToolTip("Not available for DeviantArt")
|
||||||
|
# ------------------------
|
||||||
|
|
||||||
|
# Use a lambda to pass the correct internal key when clicked
|
||||||
key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
|
key_button.clicked.connect(lambda checked, key=internal_key: self.add_key_to_input(key))
|
||||||
keys_layout.addWidget(key_button)
|
keys_layout.addWidget(key_button)
|
||||||
keys_layout.addStretch()
|
keys_layout.addStretch()
|
||||||
@@ -81,9 +103,7 @@ class CustomFilenameDialog(QDialog):
|
|||||||
self.format_input.setFocus()
|
self.format_input.setFocus()
|
||||||
|
|
||||||
def get_format_string(self):
|
def get_format_string(self):
|
||||||
"""Returns the final format string from the input field."""
|
|
||||||
return self.format_input.text().strip()
|
return self.format_input.text().strip()
|
||||||
|
|
||||||
def get_date_format_string(self):
|
def get_date_format_string(self):
|
||||||
"""Returns the date format string from its input field."""
|
|
||||||
return self.date_format_input.text().strip()
|
return self.date_format_input.text().strip()
|
||||||
@@ -156,6 +156,9 @@ class EmptyPopupDialog (QDialog ):
|
|||||||
|
|
||||||
# --- MODIFIED: Store a list of profiles now ---
|
# --- MODIFIED: Store a list of profiles now ---
|
||||||
self.update_profiles_list = None
|
self.update_profiles_list = None
|
||||||
|
# --- NEW: Flag to indicate if settings should load to UI ---
|
||||||
|
self.load_settings_into_ui_requested = False
|
||||||
|
|
||||||
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
|
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
|
||||||
self.update_profile_data = None
|
self.update_profile_data = None
|
||||||
self.update_creator_name = None
|
self.update_creator_name = None
|
||||||
@@ -341,6 +344,9 @@ class EmptyPopupDialog (QDialog ):
|
|||||||
if dialog.exec_() == QDialog.Accepted:
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
# --- MODIFIED: Get a list of profiles now ---
|
# --- MODIFIED: Get a list of profiles now ---
|
||||||
selected_profiles = dialog.get_selected_profiles()
|
selected_profiles = dialog.get_selected_profiles()
|
||||||
|
# --- NEW: Get the checkbox state ---
|
||||||
|
self.load_settings_into_ui_requested = dialog.should_load_into_ui()
|
||||||
|
|
||||||
if selected_profiles:
|
if selected_profiles:
|
||||||
try:
|
try:
|
||||||
# --- MODIFIED: Store the list ---
|
# --- MODIFIED: Store the list ---
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import sys
|
|||||||
|
|
||||||
# --- PyQt5 Imports ---
|
# --- PyQt5 Imports ---
|
||||||
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
||||||
|
from PyQt5.QtGui import QIntValidator # <--- NEW: Added for Port validation
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
|
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
|
||||||
QTabWidget, QWidget, QFileDialog # Added QFileDialog
|
QTabWidget, QWidget, QFileDialog
|
||||||
)
|
)
|
||||||
# --- Local Application Imports ---
|
# --- Local Application Imports ---
|
||||||
from ...i18n.translator import get_translation
|
from ...i18n.translator import get_translation
|
||||||
@@ -21,7 +22,9 @@ from ...config.constants import (
|
|||||||
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
|
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
|
||||||
DATE_PREFIX_FORMAT_KEY,
|
DATE_PREFIX_FORMAT_KEY,
|
||||||
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
|
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
|
||||||
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY
|
FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY,
|
||||||
|
PROXY_ENABLED_KEY, PROXY_HOST_KEY, PROXY_PORT_KEY,
|
||||||
|
PROXY_USERNAME_KEY, PROXY_PASSWORD_KEY
|
||||||
)
|
)
|
||||||
from ...services.updater import UpdateChecker, UpdateDownloader
|
from ...services.updater import UpdateChecker, UpdateDownloader
|
||||||
|
|
||||||
@@ -118,16 +121,15 @@ class FutureSettingsDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent_app = parent_app_ref
|
self.parent_app = parent_app_ref
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.update_downloader_thread = None # To keep a reference
|
self.update_downloader_thread = None
|
||||||
|
|
||||||
app_icon = get_app_icon_object()
|
app_icon = get_app_icon_object()
|
||||||
if app_icon and not app_icon.isNull():
|
if app_icon and not app_icon.isNull():
|
||||||
self.setWindowIcon(app_icon)
|
self.setWindowIcon(app_icon)
|
||||||
|
|
||||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||||
# Use a more balanced aspect ratio
|
|
||||||
scale_factor = screen_height / 1000.0
|
scale_factor = screen_height / 1000.0
|
||||||
base_min_w, base_min_h = 480, 420 # Wider, less tall
|
base_min_w, base_min_h = 550, 450 # <--- TWEAK: Slightly increased width for better layout
|
||||||
scaled_min_w = int(base_min_w * scale_factor)
|
scaled_min_w = int(base_min_w * scale_factor)
|
||||||
scaled_min_h = int(base_min_h * scale_factor)
|
scaled_min_h = int(base_min_h * scale_factor)
|
||||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||||
@@ -136,6 +138,9 @@ class FutureSettingsDialog(QDialog):
|
|||||||
self._retranslate_ui()
|
self._retranslate_ui()
|
||||||
self._apply_theme()
|
self._apply_theme()
|
||||||
|
|
||||||
|
# <--- NEW: Load proxy settings on init
|
||||||
|
self._load_proxy_settings()
|
||||||
|
|
||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
"""Initializes all UI components and layouts for the dialog."""
|
"""Initializes all UI components and layouts for the dialog."""
|
||||||
main_layout = QVBoxLayout(self)
|
main_layout = QVBoxLayout(self)
|
||||||
@@ -147,14 +152,16 @@ class FutureSettingsDialog(QDialog):
|
|||||||
# --- Create Tabs ---
|
# --- Create Tabs ---
|
||||||
self.display_tab = QWidget()
|
self.display_tab = QWidget()
|
||||||
self.downloads_tab = QWidget()
|
self.downloads_tab = QWidget()
|
||||||
|
self.network_tab = QWidget() # <--- NEW: Network Tab
|
||||||
self.updates_tab = QWidget()
|
self.updates_tab = QWidget()
|
||||||
|
|
||||||
# Add tabs to the widget
|
# Add tabs to the widget
|
||||||
self.tab_widget.addTab(self.display_tab, "Display")
|
self.tab_widget.addTab(self.display_tab, "Display")
|
||||||
self.tab_widget.addTab(self.downloads_tab, "Downloads")
|
self.tab_widget.addTab(self.downloads_tab, "Downloads")
|
||||||
|
self.tab_widget.addTab(self.network_tab, "Proxy/Network") # <--- NEW
|
||||||
self.tab_widget.addTab(self.updates_tab, "Updates")
|
self.tab_widget.addTab(self.updates_tab, "Updates")
|
||||||
|
|
||||||
# --- Populate Display Tab ---
|
# [Display Tab Code (Unchanged) ...]
|
||||||
display_tab_layout = QVBoxLayout(self.display_tab)
|
display_tab_layout = QVBoxLayout(self.display_tab)
|
||||||
self.display_group_box = QGroupBox()
|
self.display_group_box = QGroupBox()
|
||||||
display_layout = QGridLayout(self.display_group_box)
|
display_layout = QGridLayout(self.display_group_box)
|
||||||
@@ -184,9 +191,9 @@ class FutureSettingsDialog(QDialog):
|
|||||||
display_layout.addWidget(self.resolution_combo_box, 3, 1)
|
display_layout.addWidget(self.resolution_combo_box, 3, 1)
|
||||||
|
|
||||||
display_tab_layout.addWidget(self.display_group_box)
|
display_tab_layout.addWidget(self.display_group_box)
|
||||||
display_tab_layout.addStretch(1) # Push content to the top
|
display_tab_layout.addStretch(1)
|
||||||
|
|
||||||
# --- Populate Downloads Tab ---
|
# [Downloads Tab Code (Unchanged) ...]
|
||||||
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
|
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
|
||||||
self.download_settings_group_box = QGroupBox()
|
self.download_settings_group_box = QGroupBox()
|
||||||
download_settings_layout = QGridLayout(self.download_settings_group_box)
|
download_settings_layout = QGridLayout(self.download_settings_group_box)
|
||||||
@@ -217,7 +224,6 @@ class FutureSettingsDialog(QDialog):
|
|||||||
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
||||||
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
|
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
|
||||||
|
|
||||||
# --- START: Add new Load/Save buttons ---
|
|
||||||
settings_file_layout = QHBoxLayout()
|
settings_file_layout = QHBoxLayout()
|
||||||
self.load_settings_button = QPushButton()
|
self.load_settings_button = QPushButton()
|
||||||
self.save_settings_button = QPushButton()
|
self.save_settings_button = QPushButton()
|
||||||
@@ -225,18 +231,72 @@ class FutureSettingsDialog(QDialog):
|
|||||||
settings_file_layout.addWidget(self.save_settings_button)
|
settings_file_layout.addWidget(self.save_settings_button)
|
||||||
settings_file_layout.addStretch(1)
|
settings_file_layout.addStretch(1)
|
||||||
|
|
||||||
# Add this new layout to the grid
|
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2)
|
||||||
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.load_settings_button.clicked.connect(self._handle_load_settings)
|
||||||
self.save_settings_button.clicked.connect(self._handle_save_settings)
|
self.save_settings_button.clicked.connect(self._handle_save_settings)
|
||||||
# --- END: Add new Load/Save buttons ---
|
|
||||||
|
|
||||||
downloads_tab_layout.addWidget(self.download_settings_group_box)
|
downloads_tab_layout.addWidget(self.download_settings_group_box)
|
||||||
downloads_tab_layout.addStretch(1) # Push content to the top
|
downloads_tab_layout.addStretch(1)
|
||||||
|
|
||||||
# --- Populate Updates Tab ---
|
# --- START: Network Tab (NEW) ---
|
||||||
|
network_tab_layout = QVBoxLayout(self.network_tab)
|
||||||
|
self.proxy_group_box = QGroupBox()
|
||||||
|
proxy_layout = QGridLayout(self.proxy_group_box)
|
||||||
|
|
||||||
|
# Enable Checkbox
|
||||||
|
self.proxy_enabled_checkbox = QCheckBox()
|
||||||
|
self.proxy_enabled_checkbox.stateChanged.connect(self._proxy_setting_changed)
|
||||||
|
proxy_layout.addWidget(self.proxy_enabled_checkbox, 0, 0, 1, 2)
|
||||||
|
|
||||||
|
# Proxy Type Dropdown
|
||||||
|
self.proxy_type_label = QLabel("Proxy Type:")
|
||||||
|
self.proxy_type_combo = QComboBox()
|
||||||
|
self.proxy_type_combo.addItems(["HTTP", "SOCKS4", "SOCKS5"])
|
||||||
|
self.proxy_type_combo.currentIndexChanged.connect(self._proxy_setting_changed)
|
||||||
|
proxy_layout.addWidget(self.proxy_type_label, 1, 0)
|
||||||
|
proxy_layout.addWidget(self.proxy_type_combo, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# Host / IP
|
||||||
|
self.proxy_host_label = QLabel()
|
||||||
|
self.proxy_host_input = QLineEdit()
|
||||||
|
self.proxy_host_input.setPlaceholderText("127.0.0.1")
|
||||||
|
self.proxy_host_input.editingFinished.connect(self._proxy_setting_changed)
|
||||||
|
proxy_layout.addWidget(self.proxy_host_label, 2, 0) # Changed row to 2
|
||||||
|
proxy_layout.addWidget(self.proxy_host_input, 2, 1)
|
||||||
|
|
||||||
|
# Port
|
||||||
|
self.proxy_port_label = QLabel()
|
||||||
|
self.proxy_port_input = QLineEdit()
|
||||||
|
self.proxy_port_input.setPlaceholderText("8080")
|
||||||
|
self.proxy_port_input.setValidator(QIntValidator(1, 65535, self)) # Only numbers
|
||||||
|
self.proxy_port_input.editingFinished.connect(self._proxy_setting_changed)
|
||||||
|
proxy_layout.addWidget(self.proxy_port_label, 3, 0)
|
||||||
|
proxy_layout.addWidget(self.proxy_port_input, 3, 1)
|
||||||
|
|
||||||
|
# Username
|
||||||
|
self.proxy_user_label = QLabel()
|
||||||
|
self.proxy_user_input = QLineEdit()
|
||||||
|
self.proxy_user_input.setPlaceholderText("(Optional)")
|
||||||
|
self.proxy_user_input.editingFinished.connect(self._proxy_setting_changed)
|
||||||
|
proxy_layout.addWidget(self.proxy_user_label, 4, 0)
|
||||||
|
proxy_layout.addWidget(self.proxy_user_input, 4, 1)
|
||||||
|
|
||||||
|
# Password
|
||||||
|
self.proxy_pass_label = QLabel()
|
||||||
|
self.proxy_pass_input = QLineEdit()
|
||||||
|
self.proxy_pass_input.setPlaceholderText("(Optional)")
|
||||||
|
self.proxy_pass_input.setEchoMode(QLineEdit.Password) # Mask input
|
||||||
|
self.proxy_pass_input.editingFinished.connect(self._proxy_setting_changed)
|
||||||
|
proxy_layout.addWidget(self.proxy_pass_label, 5, 0)
|
||||||
|
proxy_layout.addWidget(self.proxy_pass_input, 5, 1)
|
||||||
|
|
||||||
|
network_tab_layout.addWidget(self.proxy_group_box)
|
||||||
|
network_tab_layout.addStretch(1)
|
||||||
|
# --- END: Network Tab (NEW) ---
|
||||||
|
|
||||||
|
# [Updates Tab Code (Unchanged) ...]
|
||||||
updates_tab_layout = QVBoxLayout(self.updates_tab)
|
updates_tab_layout = QVBoxLayout(self.updates_tab)
|
||||||
self.update_group_box = QGroupBox()
|
self.update_group_box = QGroupBox()
|
||||||
update_layout = QGridLayout(self.update_group_box)
|
update_layout = QGridLayout(self.update_group_box)
|
||||||
@@ -249,7 +309,7 @@ class FutureSettingsDialog(QDialog):
|
|||||||
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
||||||
|
|
||||||
updates_tab_layout.addWidget(self.update_group_box)
|
updates_tab_layout.addWidget(self.update_group_box)
|
||||||
updates_tab_layout.addStretch(1) # Push content to the top
|
updates_tab_layout.addStretch(1)
|
||||||
|
|
||||||
# --- OK Button (outside tabs) ---
|
# --- OK Button (outside tabs) ---
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
@@ -266,16 +326,17 @@ class FutureSettingsDialog(QDialog):
|
|||||||
# --- Tab Titles ---
|
# --- Tab Titles ---
|
||||||
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display"))
|
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(1, self._tr("settings_tab_downloads", "Downloads"))
|
||||||
self.tab_widget.setTabText(2, self._tr("settings_tab_updates", "Updates"))
|
self.tab_widget.setTabText(2, self._tr("settings_tab_network", "Proxy/Network")) # <--- NEW
|
||||||
|
self.tab_widget.setTabText(3, self._tr("settings_tab_updates", "Updates"))
|
||||||
|
|
||||||
# --- Display Tab ---
|
# [Display Tab (Unchanged) ...]
|
||||||
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
|
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
|
||||||
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
||||||
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
||||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||||
|
|
||||||
# --- Downloads Tab ---
|
# [Downloads Tab (Unchanged) ...]
|
||||||
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
|
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
|
||||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
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:"))
|
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
|
||||||
@@ -294,32 +355,112 @@ class FutureSettingsDialog(QDialog):
|
|||||||
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.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.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
|
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."))
|
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.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.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.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."))
|
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 ---
|
# --- START: Network Tab (NEW) ---
|
||||||
|
self.proxy_group_box.setTitle(self._tr("proxy_settings_group_title", "Proxy Configuration"))
|
||||||
|
self.proxy_enabled_checkbox.setText(self._tr("proxy_enabled_label", "Enable Proxy"))
|
||||||
|
self.proxy_host_label.setText(self._tr("proxy_host_label", "Host / IP:"))
|
||||||
|
self.proxy_port_label.setText(self._tr("proxy_port_label", "Port:"))
|
||||||
|
self.proxy_user_label.setText(self._tr("proxy_user_label", "Username (Optional):"))
|
||||||
|
self.proxy_pass_label.setText(self._tr("proxy_pass_label", "Password (Optional):"))
|
||||||
|
# --- END: Network Tab (NEW) ---
|
||||||
|
|
||||||
|
# [Updates Tab (Unchanged) ...]
|
||||||
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
||||||
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
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.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.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"))
|
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
||||||
|
|
||||||
# --- General ---
|
|
||||||
self._update_theme_toggle_button_text()
|
self._update_theme_toggle_button_text()
|
||||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||||
|
|
||||||
# --- Load Data ---
|
|
||||||
self._populate_display_combo_boxes()
|
self._populate_display_combo_boxes()
|
||||||
self._populate_language_combo_box()
|
self._populate_language_combo_box()
|
||||||
self._populate_post_download_action_combo()
|
self._populate_post_download_action_combo()
|
||||||
self._load_date_prefix_format()
|
self._load_date_prefix_format()
|
||||||
self._load_checkbox_states()
|
self._load_checkbox_states()
|
||||||
|
|
||||||
|
# --- START: New Proxy Logic ---
|
||||||
|
def _load_proxy_settings(self):
|
||||||
|
"""Loads proxy settings from QSettings into the UI."""
|
||||||
|
# Block signals to prevent triggering auto-save while loading
|
||||||
|
self.proxy_enabled_checkbox.blockSignals(True)
|
||||||
|
self.proxy_type_combo.blockSignals(True) # <--- NEW
|
||||||
|
self.proxy_host_input.blockSignals(True)
|
||||||
|
self.proxy_port_input.blockSignals(True)
|
||||||
|
self.proxy_user_input.blockSignals(True)
|
||||||
|
self.proxy_pass_input.blockSignals(True)
|
||||||
|
|
||||||
|
# Load values
|
||||||
|
enabled = self.parent_app.settings.value(PROXY_ENABLED_KEY, False, type=bool)
|
||||||
|
proxy_type = self.parent_app.settings.value("proxy_type", "HTTP", type=str) # <--- NEW
|
||||||
|
host = self.parent_app.settings.value(PROXY_HOST_KEY, "", type=str)
|
||||||
|
port = self.parent_app.settings.value(PROXY_PORT_KEY, "", type=str)
|
||||||
|
user = self.parent_app.settings.value(PROXY_USERNAME_KEY, "", type=str)
|
||||||
|
password = self.parent_app.settings.value(PROXY_PASSWORD_KEY, "", type=str)
|
||||||
|
|
||||||
|
# Apply values to UI
|
||||||
|
self.proxy_enabled_checkbox.setChecked(enabled)
|
||||||
|
|
||||||
|
# <--- NEW: Set the dropdown selection
|
||||||
|
index = self.proxy_type_combo.findText(proxy_type)
|
||||||
|
if index >= 0:
|
||||||
|
self.proxy_type_combo.setCurrentIndex(index)
|
||||||
|
else:
|
||||||
|
self.proxy_type_combo.setCurrentIndex(0) # Default to first item if not found
|
||||||
|
|
||||||
|
self.proxy_host_input.setText(host)
|
||||||
|
self.proxy_port_input.setText(port)
|
||||||
|
self.proxy_user_input.setText(user)
|
||||||
|
self.proxy_pass_input.setText(password)
|
||||||
|
|
||||||
|
self._update_proxy_fields_state(enabled)
|
||||||
|
|
||||||
|
# Unblock signals
|
||||||
|
self.proxy_enabled_checkbox.blockSignals(False)
|
||||||
|
self.proxy_type_combo.blockSignals(False) # <--- NEW
|
||||||
|
self.proxy_host_input.blockSignals(False)
|
||||||
|
self.proxy_port_input.blockSignals(False)
|
||||||
|
self.proxy_user_input.blockSignals(False)
|
||||||
|
self.proxy_pass_input.blockSignals(False)
|
||||||
|
|
||||||
|
def _proxy_setting_changed(self):
|
||||||
|
"""Saves the current proxy UI state to QSettings."""
|
||||||
|
enabled = self.proxy_enabled_checkbox.isChecked()
|
||||||
|
proxy_type = self.proxy_type_combo.currentText() # <--- NEW
|
||||||
|
host = self.proxy_host_input.text().strip()
|
||||||
|
port = self.proxy_port_input.text().strip()
|
||||||
|
user = self.proxy_user_input.text().strip()
|
||||||
|
password = self.proxy_pass_input.text().strip()
|
||||||
|
|
||||||
|
self.parent_app.settings.setValue(PROXY_ENABLED_KEY, enabled)
|
||||||
|
self.parent_app.settings.setValue("proxy_type", proxy_type) # <--- NEW
|
||||||
|
self.parent_app.settings.setValue(PROXY_HOST_KEY, host)
|
||||||
|
self.parent_app.settings.setValue(PROXY_PORT_KEY, port)
|
||||||
|
self.parent_app.settings.setValue(PROXY_USERNAME_KEY, user)
|
||||||
|
self.parent_app.settings.setValue(PROXY_PASSWORD_KEY, password)
|
||||||
|
|
||||||
|
self.parent_app.settings.sync()
|
||||||
|
|
||||||
|
self._update_proxy_fields_state(enabled)
|
||||||
|
|
||||||
|
# Optional: Notify main app that network settings changed if needed
|
||||||
|
# self.parent_app.reload_proxy_settings()
|
||||||
|
|
||||||
|
def _update_proxy_fields_state(self, enabled):
|
||||||
|
"""Enables or disables input fields based on the checkbox."""
|
||||||
|
self.proxy_type_combo.setEnabled(enabled)
|
||||||
|
self.proxy_host_input.setEnabled(enabled)
|
||||||
|
self.proxy_port_input.setEnabled(enabled)
|
||||||
|
self.proxy_user_input.setEnabled(enabled)
|
||||||
|
self.proxy_pass_input.setEnabled(enabled)
|
||||||
|
# --- END: New Proxy Logic ---
|
||||||
|
|
||||||
def _check_for_updates(self):
|
def _check_for_updates(self):
|
||||||
self.check_update_button.setEnabled(False)
|
self.check_update_button.setEnabled(False)
|
||||||
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
|
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ class HelpGuideDialog(QDialog):
|
|||||||
<li>fap-nation.org/</li>
|
<li>fap-nation.org/</li>
|
||||||
<li>Discord</li>
|
<li>Discord</li>
|
||||||
<li>allporncomic.com</li>
|
<li>allporncomic.com</li>
|
||||||
<li>allporncomic.com</li>
|
|
||||||
<li>hentai2read.com</li>
|
<li>hentai2read.com</li>
|
||||||
<li>mangadex.org</li>
|
<li>mangadex.org</li>
|
||||||
<li>Simpcity</li>
|
<li>Simpcity</li>
|
||||||
@@ -279,6 +278,46 @@ class HelpGuideDialog(QDialog):
|
|||||||
</ul>
|
</ul>
|
||||||
"""),
|
"""),
|
||||||
|
|
||||||
|
("Add to Queue",
|
||||||
|
"""
|
||||||
|
<p>This feature allows you to queue up multiple distinct downloads with different settings and run them all sequentially.</p>
|
||||||
|
|
||||||
|
<h3 style='color: #E0E0E0;'>Step 1: Prepare the Download</h3>
|
||||||
|
<p>Before clicking add, configure the download exactly how you want it processed for this specific link:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Select Directory:</b> Choose where you want the files to go.</li>
|
||||||
|
<li><b>Configure Options:</b> Check/uncheck boxes (e.g., "Separate Folders", "Use Cookie", "Manga Mode").</li>
|
||||||
|
<li><b>Paste URL:</b> Enter the link for the creator or post you want to download.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style='color: #E0E0E0;'>Step 2: Add to Queue</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click the <b>Add to Queue</b> button (located near the Start Download).</li>
|
||||||
|
<li><b>Confirmation:</b> You will see a popup message and the log will print <code>✅ Job added to queue</code>.</li>
|
||||||
|
<li>The URL box will clear, allowing you to immediately paste the next link.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 style='color: #E0E0E0;'>Step 3: Repeat & Start</h3>
|
||||||
|
<p>You can repeat steps 1 and 2 as many times as you like. You can even change settings (like the download folder) between adds; the queue remembers the specific settings for each individual link.</p>
|
||||||
|
<p>To start processing the queue:</p>
|
||||||
|
<ol>
|
||||||
|
<li>In the Link Input box, type exactly: <code>start queue</code></li>
|
||||||
|
<li>The main "Start Download" button will change to <b>"🚀 Execute Queue"</b>.</li>
|
||||||
|
<li>Click that button to begin.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 style='color: #E0E0E0;'>Processing Behavior</h3>
|
||||||
|
<p>Once started, the app will lock the UI, load the first job, download it until finished, and automatically move to the next until the queue is empty.</p>
|
||||||
|
|
||||||
|
<h3 style='color: #E0E0E0;'>Special Case: Creator Selection Popup</h3>
|
||||||
|
<p>If you use the <b>Creator Selection</b> popup (the 🎨 button):</p>
|
||||||
|
<ul>
|
||||||
|
<li>Select multiple creators in that popup and click <b>"Queue Selected"</b>.</li>
|
||||||
|
<li>The app internally adds them to a temporary list.</li>
|
||||||
|
<li>When you click the main <b>"Add to Queue"</b> button on the main window, it will detect that list and automatically bulk-create job files for all the creators you selected.</li>
|
||||||
|
</ul>
|
||||||
|
"""),
|
||||||
|
|
||||||
("Special Commands",
|
("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>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>
|
||||||
@@ -451,6 +490,15 @@ class HelpGuideDialog(QDialog):
|
|||||||
"""
|
"""
|
||||||
<p>These features provide advanced control over your downloads, sessions, and application settings.</p>
|
<p>These features provide advanced control over your downloads, sessions, and application settings.</p>
|
||||||
|
|
||||||
|
<h3 style='color: #E0E0E0;'>🛡️ Proxy Support </h3>
|
||||||
|
<p>You can now configure a proxy to bypass region blocks or ISP restrictions (e.g., for AllComic or Nhentai).</p>
|
||||||
|
<p>Go to <b>Settings ⚙️ > Proxy Tab</b> to set it up:</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Protocols:</b> Full support for <b>HTTP</b>, <b>SOCKS4</b>, and <b>SOCKS5</b>.</li>
|
||||||
|
<li><b>Authentication:</b> Supports username and password for private proxies.</li>
|
||||||
|
<li><b>Global Effect:</b> Once enabled, all app connections (including API fetches and file downloads) will route through this proxy.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3 style='color: #E0E0E0;'>Use Cookie</h3>
|
<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>
|
<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>
|
<ul>
|
||||||
@@ -484,6 +532,7 @@ class HelpGuideDialog(QDialog):
|
|||||||
<li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
|
<li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
<li><b>Proxy Tab:</b> Configure HTTP/SOCKS proxies and authentication.</li>
|
||||||
<li><b>Updates Tab:</b> Check for and install new application updates.</li>
|
<li><b>Updates Tab:</b> Check for and install new application updates.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -605,7 +654,8 @@ class HelpGuideDialog(QDialog):
|
|||||||
main_layout.addLayout(content_layout, 1)
|
main_layout.addLayout(content_layout, 1)
|
||||||
|
|
||||||
self.nav_list = QListWidget()
|
self.nav_list = QListWidget()
|
||||||
self.nav_list.setFixedWidth(int(220 * scale))
|
# Increased width to prevent scrollbar overlap
|
||||||
|
self.nav_list.setFixedWidth(int(280 * scale))
|
||||||
# Styles are now set in the __init__ method
|
# Styles are now set in the __init__ method
|
||||||
content_layout.addWidget(self.nav_list)
|
content_layout.addWidget(self.nav_list)
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,16 @@ class MoreOptionsDialog(QDialog):
|
|||||||
SCOPE_CONTENT = "content"
|
SCOPE_CONTENT = "content"
|
||||||
SCOPE_COMMENTS = "comments"
|
SCOPE_COMMENTS = "comments"
|
||||||
|
|
||||||
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
|
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False, add_info_checked=False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent_app = parent
|
self.parent_app = parent
|
||||||
self.setWindowTitle("More Options")
|
self.setWindowTitle("More Options")
|
||||||
self.setMinimumWidth(350)
|
self.setMinimumWidth(350)
|
||||||
|
|
||||||
# ... (Layout and other widgets remain the same) ...
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
self.description_label = QLabel("Please choose the scope for the action:")
|
self.description_label = QLabel("Please choose the scope for the action:")
|
||||||
layout.addWidget(self.description_label)
|
layout.addWidget(self.description_label)
|
||||||
|
|
||||||
self.radio_button_group = QButtonGroup(self)
|
self.radio_button_group = QButtonGroup(self)
|
||||||
self.radio_content = QRadioButton("Description/Content")
|
self.radio_content = QRadioButton("Description/Content")
|
||||||
self.radio_comments = QRadioButton("Comments")
|
self.radio_comments = QRadioButton("Comments")
|
||||||
@@ -50,14 +49,20 @@ class MoreOptionsDialog(QDialog):
|
|||||||
export_layout.addStretch()
|
export_layout.addStretch()
|
||||||
layout.addLayout(export_layout)
|
layout.addLayout(export_layout)
|
||||||
|
|
||||||
# --- UPDATED: Single PDF Checkbox ---
|
# --- Single PDF Checkbox ---
|
||||||
self.single_pdf_checkbox = QCheckBox("Single PDF")
|
self.single_pdf_checkbox = QCheckBox("Single PDF")
|
||||||
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
|
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
|
||||||
self.single_pdf_checkbox.setChecked(single_pdf_checked)
|
self.single_pdf_checkbox.setChecked(single_pdf_checked)
|
||||||
layout.addWidget(self.single_pdf_checkbox)
|
layout.addWidget(self.single_pdf_checkbox)
|
||||||
|
|
||||||
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
|
# --- NEW: Add Info Checkbox ---
|
||||||
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
|
self.add_info_checkbox = QCheckBox("Add info in PDF")
|
||||||
|
self.add_info_checkbox.setToolTip("If checked, adds a first page with post details (Title, Date, Link, Creator, Tags, etc.).")
|
||||||
|
self.add_info_checkbox.setChecked(add_info_checked)
|
||||||
|
layout.addWidget(self.add_info_checkbox)
|
||||||
|
|
||||||
|
self.format_combo.currentTextChanged.connect(self.update_checkbox_states)
|
||||||
|
self.update_checkbox_states(self.format_combo.currentText())
|
||||||
|
|
||||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
self.button_box.accepted.connect(self.accept)
|
self.button_box.accepted.connect(self.accept)
|
||||||
@@ -65,12 +70,18 @@ class MoreOptionsDialog(QDialog):
|
|||||||
layout.addWidget(self.button_box)
|
layout.addWidget(self.button_box)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
self._apply_theme()
|
self._apply_theme()
|
||||||
def update_single_pdf_checkbox_state(self, text):
|
|
||||||
"""Enable the Single PDF checkbox only if the format is PDF."""
|
def update_checkbox_states(self, text):
|
||||||
|
"""Enable PDF-specific checkboxes only if the format is PDF."""
|
||||||
is_pdf = (text.upper() == "PDF")
|
is_pdf = (text.upper() == "PDF")
|
||||||
self.single_pdf_checkbox.setEnabled(is_pdf)
|
self.single_pdf_checkbox.setEnabled(is_pdf)
|
||||||
|
self.add_info_checkbox.setEnabled(is_pdf)
|
||||||
|
|
||||||
if not is_pdf:
|
if not is_pdf:
|
||||||
self.single_pdf_checkbox.setChecked(False)
|
self.single_pdf_checkbox.setChecked(False)
|
||||||
|
# We don't uncheck add_info necessarily, just disable it,
|
||||||
|
# but unchecking is safer visually to imply "won't happen"
|
||||||
|
self.add_info_checkbox.setChecked(False)
|
||||||
|
|
||||||
def get_selected_scope(self):
|
def get_selected_scope(self):
|
||||||
if self.radio_comments.isChecked():
|
if self.radio_comments.isChecked():
|
||||||
@@ -84,13 +95,14 @@ class MoreOptionsDialog(QDialog):
|
|||||||
"""Returns the state of the Single PDF checkbox."""
|
"""Returns the state of the Single PDF checkbox."""
|
||||||
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
|
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
|
||||||
|
|
||||||
|
def get_add_info_state(self):
|
||||||
|
"""Returns the state of the Add Info checkbox."""
|
||||||
|
return self.add_info_checkbox.isChecked() and self.add_info_checkbox.isEnabled()
|
||||||
|
|
||||||
def _apply_theme(self):
|
def _apply_theme(self):
|
||||||
"""Applies the current theme from the parent application."""
|
"""Applies the current theme from the parent application."""
|
||||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||||
# Get the scale factor from the parent app
|
|
||||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||||
# Call the imported function with the correct scale
|
|
||||||
self.setStyleSheet(get_dark_theme(scale))
|
self.setStyleSheet(get_dark_theme(scale))
|
||||||
else:
|
else:
|
||||||
# Explicitly set a blank stylesheet for light mode
|
|
||||||
self.setStyleSheet("")
|
self.setStyleSheet("")
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fpdf import FPDF
|
from fpdf import FPDF
|
||||||
FPDF_AVAILABLE = True
|
FPDF_AVAILABLE = True
|
||||||
|
|
||||||
# --- FIX: Move the class definition inside the try block ---
|
|
||||||
class PDF(FPDF):
|
class PDF(FPDF):
|
||||||
"""Custom PDF class to handle headers and footers."""
|
"""Custom PDF class to handle headers and footers."""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.font_family_main = 'Arial'
|
||||||
|
|
||||||
def header(self):
|
def header(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def footer(self):
|
def footer(self):
|
||||||
self.set_y(-15)
|
self.set_y(-15)
|
||||||
if self.font_family:
|
self.set_font(self.font_family_main, '', 8)
|
||||||
self.set_font(self.font_family, '', 8)
|
|
||||||
else:
|
|
||||||
self.set_font('Arial', '', 8)
|
|
||||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||||
|
|
||||||
except ImportError:
|
except Exception as e:
|
||||||
|
print(f"\n❌ DEBUG INFO: Import failed. The specific error is: {e}")
|
||||||
|
print(f"❌ DEBUG INFO: Python running this script is located at: {sys.executable}\n")
|
||||||
FPDF_AVAILABLE = False
|
FPDF_AVAILABLE = False
|
||||||
# If the import fails, FPDF and PDF will not be defined,
|
|
||||||
# but the program won't crash here.
|
|
||||||
FPDF = None
|
FPDF = None
|
||||||
PDF = None
|
PDF = None
|
||||||
|
|
||||||
@@ -31,12 +33,169 @@ def strip_html_tags(text):
|
|||||||
clean = re.compile('<.*?>')
|
clean = re.compile('<.*?>')
|
||||||
return re.sub(clean, '', text)
|
return re.sub(clean, '', text)
|
||||||
|
|
||||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
def _setup_pdf_fonts(pdf, font_path, logger=print):
|
||||||
|
"""Helper to setup fonts for the PDF instance."""
|
||||||
|
bold_font_path = ""
|
||||||
|
default_font = 'Arial'
|
||||||
|
|
||||||
|
if font_path:
|
||||||
|
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if font_path and os.path.exists(font_path):
|
||||||
|
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||||
|
default_font = 'DejaVu'
|
||||||
|
if os.path.exists(bold_font_path):
|
||||||
|
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||||
|
else:
|
||||||
|
pdf.add_font('DejaVu', 'B', font_path, uni=True)
|
||||||
|
except Exception as font_error:
|
||||||
|
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||||
|
default_font = 'Arial'
|
||||||
|
|
||||||
|
pdf.font_family_main = default_font
|
||||||
|
return default_font
|
||||||
|
|
||||||
|
def add_metadata_page(pdf, post, font_family):
|
||||||
|
"""Adds a dedicated metadata page to the PDF with clickable links."""
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.set_font(font_family, 'B', 16)
|
||||||
|
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='C')
|
||||||
|
pdf.ln(10)
|
||||||
|
pdf.set_font(font_family, '', 11)
|
||||||
|
|
||||||
|
def add_info_row(label, value, link_url=None):
|
||||||
|
if not value: return
|
||||||
|
|
||||||
|
# Write Label (Bold)
|
||||||
|
pdf.set_font(font_family, 'B', 11)
|
||||||
|
pdf.write(8, f"{label}: ")
|
||||||
|
|
||||||
|
# Write Value
|
||||||
|
if link_url:
|
||||||
|
# Styling for clickable link: Blue + Underline
|
||||||
|
pdf.set_text_color(0, 0, 255)
|
||||||
|
# Check if font supports underline style directly or just use 'U'
|
||||||
|
# FPDF standard allows 'U' in style string.
|
||||||
|
# We use 'U' combined with the font family.
|
||||||
|
# Note: DejaVu implementation in fpdf2 might handle 'U' automatically or ignore it depending on version,
|
||||||
|
# but setting text color indicates link clearly enough usually.
|
||||||
|
pdf.set_font(font_family, 'U', 11)
|
||||||
|
|
||||||
|
# Pass the URL to the 'link' parameter
|
||||||
|
pdf.multi_cell(w=0, h=8, txt=str(value), link=link_url)
|
||||||
|
|
||||||
|
# Reset styles
|
||||||
|
pdf.set_text_color(0, 0, 0)
|
||||||
|
pdf.set_font(font_family, '', 11)
|
||||||
|
else:
|
||||||
|
pdf.set_font(font_family, '', 11)
|
||||||
|
pdf.multi_cell(w=0, h=8, txt=str(value))
|
||||||
|
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
date_str = post.get('published') or post.get('added') or 'Unknown'
|
||||||
|
add_info_row("Date Uploaded", date_str)
|
||||||
|
|
||||||
|
creator = post.get('creator_name') or post.get('user') or 'Unknown'
|
||||||
|
add_info_row("Creator", creator)
|
||||||
|
|
||||||
|
add_info_row("Service", post.get('service', 'Unknown'))
|
||||||
|
|
||||||
|
link = post.get('original_link')
|
||||||
|
if not link and post.get('service') and post.get('user') and post.get('id'):
|
||||||
|
link = f"https://kemono.su/{post['service']}/user/{post['user']}/post/{post['id']}"
|
||||||
|
|
||||||
|
# Pass 'link' as both the text value AND the URL target
|
||||||
|
add_info_row("Original Link", link, link_url=link)
|
||||||
|
|
||||||
|
tags = post.get('tags')
|
||||||
|
if tags:
|
||||||
|
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
|
||||||
|
add_info_row("Tags", tags_str)
|
||||||
|
|
||||||
|
pdf.ln(10)
|
||||||
|
pdf.cell(0, 0, border='T')
|
||||||
|
pdf.ln(10)
|
||||||
|
|
||||||
|
def create_individual_pdf(post_data, output_filename, font_path, add_info_page=False, add_comments=False, logger=print):
|
||||||
"""
|
"""
|
||||||
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
|
Creates a PDF for a single post.
|
||||||
|
Supports optional metadata page and appending comments.
|
||||||
"""
|
"""
|
||||||
if not FPDF_AVAILABLE:
|
if not FPDF_AVAILABLE:
|
||||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
logger("❌ PDF Creation failed: 'fpdf2' library not installed.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
pdf = PDF()
|
||||||
|
font_family = _setup_pdf_fonts(pdf, font_path, logger)
|
||||||
|
|
||||||
|
if add_info_page:
|
||||||
|
# add_metadata_page adds the page start itself
|
||||||
|
add_metadata_page(pdf, post_data, font_family)
|
||||||
|
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
|
||||||
|
else:
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Only add the Title header manually if we didn't add the info page
|
||||||
|
# (Because the info page already contains the title at the top)
|
||||||
|
if not add_info_page:
|
||||||
|
pdf.set_font(font_family, 'B', 16)
|
||||||
|
pdf.multi_cell(w=0, h=10, txt=post_data.get('title', 'Untitled Post'), align='L')
|
||||||
|
pdf.ln(5)
|
||||||
|
|
||||||
|
content_text = post_data.get('content_text_for_pdf')
|
||||||
|
comments_list = post_data.get('comments_list_for_pdf')
|
||||||
|
|
||||||
|
# 1. Write Content
|
||||||
|
if content_text:
|
||||||
|
pdf.set_font(font_family, '', 12)
|
||||||
|
pdf.multi_cell(w=0, h=7, txt=content_text)
|
||||||
|
pdf.ln(10)
|
||||||
|
|
||||||
|
# 2. Write Comments (if enabled and present)
|
||||||
|
if comments_list and (add_comments or not content_text):
|
||||||
|
if add_comments and content_text:
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.set_font(font_family, 'B', 14)
|
||||||
|
pdf.cell(0, 10, "Comments", ln=True)
|
||||||
|
pdf.ln(5)
|
||||||
|
|
||||||
|
for i, comment in enumerate(comments_list):
|
||||||
|
user = comment.get('commenter_name', 'Unknown User')
|
||||||
|
timestamp = comment.get('published', 'No Date')
|
||||||
|
body = strip_html_tags(comment.get('content', ''))
|
||||||
|
|
||||||
|
pdf.set_font(font_family, '', 10)
|
||||||
|
pdf.write(8, "Comment by: ")
|
||||||
|
pdf.set_font(font_family, 'B', 10)
|
||||||
|
pdf.write(8, str(user))
|
||||||
|
|
||||||
|
pdf.set_font(font_family, '', 10)
|
||||||
|
pdf.write(8, f" on {timestamp}")
|
||||||
|
pdf.ln(10)
|
||||||
|
|
||||||
|
pdf.set_font(font_family, '', 11)
|
||||||
|
pdf.multi_cell(w=0, h=7, txt=body)
|
||||||
|
|
||||||
|
if i < len(comments_list) - 1:
|
||||||
|
pdf.ln(3)
|
||||||
|
pdf.cell(w=0, h=0, border='T')
|
||||||
|
pdf.ln(3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf.output(output_filename)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger(f"❌ Error saving PDF '{os.path.basename(output_filename)}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_single_pdf_from_content(posts_data, output_filename, font_path, add_info_page=False, logger=print):
|
||||||
|
"""
|
||||||
|
Creates a single, continuous PDF from multiple posts.
|
||||||
|
"""
|
||||||
|
if not FPDF_AVAILABLE:
|
||||||
|
logger("❌ PDF Creation failed: 'fpdf2' library is not installed.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not posts_data:
|
if not posts_data:
|
||||||
@@ -44,32 +203,19 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
pdf = PDF()
|
pdf = PDF()
|
||||||
default_font_family = 'DejaVu'
|
font_family = _setup_pdf_fonts(pdf, font_path, logger)
|
||||||
|
|
||||||
bold_font_path = ""
|
|
||||||
if font_path:
|
|
||||||
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
|
||||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
|
||||||
|
|
||||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
|
||||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
|
||||||
except Exception as font_error:
|
|
||||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
|
||||||
default_font_family = 'Arial'
|
|
||||||
|
|
||||||
pdf.add_page()
|
|
||||||
|
|
||||||
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||||
|
|
||||||
for i, post in enumerate(posts_data):
|
for i, post in enumerate(posts_data):
|
||||||
if i > 0:
|
if add_info_page:
|
||||||
# This ensures every post after the first gets its own page.
|
add_metadata_page(pdf, post, font_family)
|
||||||
|
# REMOVED: pdf.add_page() <-- This ensures content starts right below the line
|
||||||
|
else:
|
||||||
pdf.add_page()
|
pdf.add_page()
|
||||||
|
|
||||||
pdf.set_font(default_font_family, 'B', 16)
|
if not add_info_page:
|
||||||
|
pdf.set_font(font_family, 'B', 16)
|
||||||
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
pdf.multi_cell(w=0, h=10, txt=post.get('title', 'Untitled Post'), align='L')
|
||||||
pdf.ln(5)
|
pdf.ln(5)
|
||||||
|
|
||||||
@@ -80,17 +226,17 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
|||||||
timestamp = comment.get('published', 'No Date')
|
timestamp = comment.get('published', 'No Date')
|
||||||
body = strip_html_tags(comment.get('content', ''))
|
body = strip_html_tags(comment.get('content', ''))
|
||||||
|
|
||||||
pdf.set_font(default_font_family, '', 10)
|
pdf.set_font(font_family, '', 10)
|
||||||
pdf.write(8, "Comment by: ")
|
pdf.write(8, "Comment by: ")
|
||||||
if user is not None:
|
if user is not None:
|
||||||
pdf.set_font(default_font_family, 'B', 10)
|
pdf.set_font(font_family, 'B', 10)
|
||||||
pdf.write(8, str(user))
|
pdf.write(8, str(user))
|
||||||
|
|
||||||
pdf.set_font(default_font_family, '', 10)
|
pdf.set_font(font_family, '', 10)
|
||||||
pdf.write(8, f" on {timestamp}")
|
pdf.write(8, f" on {timestamp}")
|
||||||
pdf.ln(10)
|
pdf.ln(10)
|
||||||
|
|
||||||
pdf.set_font(default_font_family, '', 11)
|
pdf.set_font(font_family, '', 11)
|
||||||
pdf.multi_cell(w=0, h=7, txt=body)
|
pdf.multi_cell(w=0, h=7, txt=body)
|
||||||
|
|
||||||
if comment_index < len(comments_list) - 1:
|
if comment_index < len(comments_list) - 1:
|
||||||
@@ -98,10 +244,13 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
|||||||
pdf.cell(w=0, h=0, border='T')
|
pdf.cell(w=0, h=0, border='T')
|
||||||
pdf.ln(3)
|
pdf.ln(3)
|
||||||
elif 'content' in post:
|
elif 'content' in post:
|
||||||
pdf.set_font(default_font_family, '', 12)
|
pdf.set_font(font_family, '', 12)
|
||||||
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
|
pdf.multi_cell(w=0, h=7, txt=post.get('content', 'No Content'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
output_dir = os.path.dirname(output_filename)
|
||||||
|
if output_dir and not os.path.exists(output_dir):
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
pdf.output(output_filename)
|
pdf.output(output_filename)
|
||||||
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
|
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sys
|
|||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PyQt5.QtCore import Qt, pyqtSignal
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||||
QPushButton, QMessageBox, QAbstractItemView, QLabel
|
QPushButton, QMessageBox, QAbstractItemView, QLabel, QCheckBox
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Local Application Imports ---
|
# --- Local Application Imports ---
|
||||||
@@ -27,6 +27,11 @@ class UpdateCheckDialog(QDialog):
|
|||||||
self.user_data_path = user_data_path
|
self.user_data_path = user_data_path
|
||||||
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
|
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
|
||||||
|
|
||||||
|
self._default_checkbox_tooltip = (
|
||||||
|
"If checked, the settings fields will be unlocked and editable.\n"
|
||||||
|
"If unchecked, settings will still load, but in 'Read-Only' mode."
|
||||||
|
)
|
||||||
|
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
self._load_profiles()
|
self._load_profiles()
|
||||||
self._retranslate_ui()
|
self._retranslate_ui()
|
||||||
@@ -56,9 +61,21 @@ class UpdateCheckDialog(QDialog):
|
|||||||
self.list_widget = QListWidget()
|
self.list_widget = QListWidget()
|
||||||
# No selection mode, we only care about checkboxes
|
# No selection mode, we only care about checkboxes
|
||||||
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
||||||
|
# Connect signal to handle checkbox state changes
|
||||||
|
self.list_widget.itemChanged.connect(self._handle_item_changed)
|
||||||
layout.addWidget(self.list_widget)
|
layout.addWidget(self.list_widget)
|
||||||
|
|
||||||
# --- All Buttons in One Horizontal Layout ---
|
# Renamed text to reflect new behavior
|
||||||
|
self.edit_settings_checkbox = QCheckBox("Enable Editing (Unlock Settings)")
|
||||||
|
self.edit_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
|
||||||
|
|
||||||
|
# Checked by default as requested
|
||||||
|
self.edit_settings_checkbox.setChecked(True)
|
||||||
|
|
||||||
|
layout.addWidget(self.edit_settings_checkbox)
|
||||||
|
# -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setSpacing(6) # small even spacing between all buttons
|
button_layout.setSpacing(6) # small even spacing between all buttons
|
||||||
|
|
||||||
@@ -97,6 +114,8 @@ class UpdateCheckDialog(QDialog):
|
|||||||
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
|
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
|
||||||
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
|
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
|
||||||
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
|
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
|
||||||
|
# Updated translation key and default text
|
||||||
|
self.edit_settings_checkbox.setText(self._tr("update_check_enable_editing_checkbox", "Enable Editing (Unlock Settings)"))
|
||||||
|
|
||||||
def _load_profiles(self):
|
def _load_profiles(self):
|
||||||
"""Loads all .json files from the creator_profiles directory as checkable items."""
|
"""Loads all .json files from the creator_profiles directory as checkable items."""
|
||||||
@@ -119,7 +138,6 @@ class UpdateCheckDialog(QDialog):
|
|||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
# Basic validation to ensure it's a valid profile
|
|
||||||
if 'creator_url' in data and 'processed_post_ids' in data:
|
if 'creator_url' in data and 'processed_post_ids' in data:
|
||||||
creator_name = os.path.splitext(filename)[0]
|
creator_name = os.path.splitext(filename)[0]
|
||||||
profiles_found.append({'name': creator_name, 'data': data})
|
profiles_found.append({'name': creator_name, 'data': data})
|
||||||
@@ -133,7 +151,6 @@ class UpdateCheckDialog(QDialog):
|
|||||||
for profile_info in profiles_found:
|
for profile_info in profiles_found:
|
||||||
item = QListWidgetItem(profile_info['name'])
|
item = QListWidgetItem(profile_info['name'])
|
||||||
item.setData(Qt.UserRole, profile_info)
|
item.setData(Qt.UserRole, profile_info)
|
||||||
# --- Make item checkable ---
|
|
||||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||||
item.setCheckState(Qt.Unchecked)
|
item.setCheckState(Qt.Unchecked)
|
||||||
self.list_widget.addItem(item)
|
self.list_widget.addItem(item)
|
||||||
@@ -144,16 +161,42 @@ class UpdateCheckDialog(QDialog):
|
|||||||
self.check_button.setEnabled(False)
|
self.check_button.setEnabled(False)
|
||||||
self.select_all_button.setEnabled(False)
|
self.select_all_button.setEnabled(False)
|
||||||
self.deselect_all_button.setEnabled(False)
|
self.deselect_all_button.setEnabled(False)
|
||||||
|
self.edit_settings_checkbox.setEnabled(False)
|
||||||
|
|
||||||
def _toggle_all_checkboxes(self):
|
def _toggle_all_checkboxes(self):
|
||||||
"""Handles Select All and Deselect All button clicks."""
|
"""Handles Select All and Deselect All button clicks."""
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
|
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
|
||||||
|
|
||||||
|
self.list_widget.blockSignals(True)
|
||||||
for i in range(self.list_widget.count()):
|
for i in range(self.list_widget.count()):
|
||||||
item = self.list_widget.item(i)
|
item = self.list_widget.item(i)
|
||||||
if item.flags() & Qt.ItemIsUserCheckable:
|
if item.flags() & Qt.ItemIsUserCheckable:
|
||||||
item.setCheckState(check_state)
|
item.setCheckState(check_state)
|
||||||
|
self.list_widget.blockSignals(False)
|
||||||
|
|
||||||
|
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 'Enable Editing' 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.edit_settings_checkbox.setChecked(False)
|
||||||
|
self.edit_settings_checkbox.setEnabled(False)
|
||||||
|
self.edit_settings_checkbox.setToolTip(
|
||||||
|
self._tr("update_check_multi_selection_warning",
|
||||||
|
"Editing settings is disabled when multiple profiles are selected.")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.edit_settings_checkbox.setEnabled(True)
|
||||||
|
self.edit_settings_checkbox.setToolTip(self._default_checkbox_tooltip)
|
||||||
|
|
||||||
def on_check_selected(self):
|
def on_check_selected(self):
|
||||||
"""Handles the 'Check Selected' button click."""
|
"""Handles the 'Check Selected' button click."""
|
||||||
@@ -177,3 +220,20 @@ class UpdateCheckDialog(QDialog):
|
|||||||
def get_selected_profiles(self):
|
def get_selected_profiles(self):
|
||||||
"""Returns the list of profile data selected by the user."""
|
"""Returns the list of profile data selected by the user."""
|
||||||
return self.selected_profiles_list
|
return self.selected_profiles_list
|
||||||
|
|
||||||
|
def should_load_into_ui(self):
|
||||||
|
"""
|
||||||
|
Returns True if the settings SHOULD be loaded into the UI.
|
||||||
|
|
||||||
|
NEW LOGIC: Returns True if exactly ONE profile is selected.
|
||||||
|
It does NOT care about the checkbox state anymore, because we want
|
||||||
|
to load settings even if the user can't edit them.
|
||||||
|
"""
|
||||||
|
return len(self.selected_profiles_list) == 1
|
||||||
|
|
||||||
|
def should_enable_editing(self):
|
||||||
|
"""
|
||||||
|
NEW METHOD: Returns True if the user is allowed to edit the settings.
|
||||||
|
This is linked to the checkbox.
|
||||||
|
"""
|
||||||
|
return self.edit_settings_checkbox.isEnabled() and self.edit_settings_checkbox.isChecked()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,12 @@ def extract_post_info(url_string):
|
|||||||
|
|
||||||
stripped_url = url_string.strip()
|
stripped_url = url_string.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# --- DeviantArt Check ---
|
||||||
|
if 'deviantart.com' in stripped_url.lower() or 'fav.me' in stripped_url.lower():
|
||||||
|
# This MUST return 'deviantart' as the first element
|
||||||
|
return 'deviantart', 'placeholder_user', 'placeholder_id' # ----------------------
|
||||||
|
|
||||||
# --- Rule34Video Check ---
|
# --- Rule34Video Check ---
|
||||||
rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
|
rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
|
||||||
if rule34video_match:
|
if rule34video_match:
|
||||||
|
|||||||
@@ -309,12 +309,16 @@ def setup_ui(main_app):
|
|||||||
|
|
||||||
# Checkbox row
|
# Checkbox row
|
||||||
simpcity_checkboxes_layout = QHBoxLayout()
|
simpcity_checkboxes_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
main_app.simpcity_dl_images_cb = QCheckBox("Download Images")
|
||||||
|
main_app.simpcity_dl_images_cb.setChecked(True) # Checked by default
|
||||||
main_app.simpcity_dl_pixeldrain_cb = QCheckBox("Download Pixeldrain")
|
main_app.simpcity_dl_pixeldrain_cb = QCheckBox("Download Pixeldrain")
|
||||||
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
|
main_app.simpcity_dl_saint2_cb = QCheckBox("Download Saint2.su")
|
||||||
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
|
main_app.simpcity_dl_mega_cb = QCheckBox("Download Mega")
|
||||||
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
|
main_app.simpcity_dl_bunkr_cb = QCheckBox("Download Bunkr")
|
||||||
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile")
|
main_app.simpcity_dl_gofile_cb = QCheckBox("Download Gofile")
|
||||||
|
|
||||||
|
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_images_cb)
|
||||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_pixeldrain_cb)
|
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_pixeldrain_cb)
|
||||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
|
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_saint2_cb)
|
||||||
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_cb)
|
simpcity_checkboxes_layout.addWidget(main_app.simpcity_dl_mega_cb)
|
||||||
@@ -324,7 +328,6 @@ def setup_ui(main_app):
|
|||||||
simpcity_settings_layout.addLayout(simpcity_checkboxes_layout)
|
simpcity_settings_layout.addLayout(simpcity_checkboxes_layout)
|
||||||
|
|
||||||
# --- START NEW CODE ---
|
# --- START NEW CODE ---
|
||||||
# Create the second, dedicated set of cookie controls for SimpCity
|
|
||||||
simpcity_cookie_layout = QHBoxLayout()
|
simpcity_cookie_layout = QHBoxLayout()
|
||||||
simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
|
simpcity_cookie_layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
|
||||||
simpcity_cookie_label = QLabel("Cookie:")
|
simpcity_cookie_label = QLabel("Cookie:")
|
||||||
@@ -344,7 +347,6 @@ def setup_ui(main_app):
|
|||||||
left_layout.addLayout(checkboxes_group_layout)
|
left_layout.addLayout(checkboxes_group_layout)
|
||||||
|
|
||||||
# --- Action Buttons & Remaining UI ---
|
# --- Action Buttons & Remaining UI ---
|
||||||
# ... (The rest of the setup_ui function remains unchanged)
|
|
||||||
main_app.standard_action_buttons_widget = QWidget()
|
main_app.standard_action_buttons_widget = QWidget()
|
||||||
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
|
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
|
||||||
btn_layout.setContentsMargins(0, 10, 0, 0)
|
btn_layout.setContentsMargins(0, 10, 0, 0)
|
||||||
@@ -354,6 +356,11 @@ def setup_ui(main_app):
|
|||||||
font.setBold(True)
|
font.setBold(True)
|
||||||
main_app.download_btn.setFont(font)
|
main_app.download_btn.setFont(font)
|
||||||
main_app.download_btn.clicked.connect(main_app.start_download)
|
main_app.download_btn.clicked.connect(main_app.start_download)
|
||||||
|
|
||||||
|
main_app.add_queue_btn = QPushButton("➕ Add to Queue")
|
||||||
|
main_app.add_queue_btn.setToolTip("Save current settings as a job for later execution.")
|
||||||
|
main_app.add_queue_btn.clicked.connect(main_app.add_current_settings_to_queue)
|
||||||
|
|
||||||
main_app.pause_btn = QPushButton("⏸️ Pause Download")
|
main_app.pause_btn = QPushButton("⏸️ Pause Download")
|
||||||
main_app.pause_btn.setEnabled(False)
|
main_app.pause_btn.setEnabled(False)
|
||||||
main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action)
|
main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action)
|
||||||
@@ -364,6 +371,7 @@ def setup_ui(main_app):
|
|||||||
main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.")
|
main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.")
|
||||||
main_app.error_btn.setEnabled(True)
|
main_app.error_btn.setEnabled(True)
|
||||||
btn_layout.addWidget(main_app.download_btn)
|
btn_layout.addWidget(main_app.download_btn)
|
||||||
|
btn_layout.addWidget(main_app.add_queue_btn)
|
||||||
btn_layout.addWidget(main_app.pause_btn)
|
btn_layout.addWidget(main_app.pause_btn)
|
||||||
btn_layout.addWidget(main_app.cancel_btn)
|
btn_layout.addWidget(main_app.cancel_btn)
|
||||||
btn_layout.addWidget(main_app.error_btn)
|
btn_layout.addWidget(main_app.error_btn)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
│ ├── DejaVuSansCondensed-BoldOblique.ttf
|
│ ├── DejaVuSansCondensed-BoldOblique.ttf
|
||||||
│ ├── DejaVuSansCondensed-Oblique.ttf
|
│ ├── DejaVuSansCondensed-Oblique.ttf
|
||||||
│ └── DejaVuSansCondensed.ttf
|
│ └── DejaVuSansCondensed.ttf
|
||||||
├── directory_tree.txt
|
|
||||||
├── main.py
|
├── main.py
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
|
|||||||
Reference in New Issue
Block a user