diff --git a/src/core/allcomic_client.py b/src/core/allcomic_client.py
index 91ba1c4..98de4e2 100644
--- a/src/core/allcomic_client.py
+++ b/src/core/allcomic_client.py
@@ -1,36 +1,36 @@
import requests
import re
from bs4 import BeautifulSoup
-import cloudscraper
import time
+import random
from urllib.parse import urlparse
-def get_chapter_list(series_url, logger_func):
+def get_chapter_list(scraper, series_url, logger_func):
"""
Checks if a URL is a series page and returns a list of all chapter URLs if it is.
- Includes a retry mechanism for robust connection.
+ Relies on a passed-in scraper session for connection.
"""
logger_func(f" [AllComic] Checking for chapter list at: {series_url}")
- scraper = cloudscraper.create_scraper()
+ headers = {'Referer': 'https://allporncomic.com/'}
response = None
max_retries = 8
for attempt in range(max_retries):
try:
- response = scraper.get(series_url, timeout=30)
+ response = scraper.get(series_url, headers=headers, timeout=30)
response.raise_for_status()
logger_func(f" [AllComic] Successfully connected to series page on attempt {attempt + 1}.")
- break # Success, exit the loop
+ break
except requests.RequestException as e:
logger_func(f" [AllComic] ⚠️ Series page check attempt {attempt + 1}/{max_retries} failed: {e}")
if attempt < max_retries - 1:
- wait_time = 2 * (attempt + 1)
- logger_func(f" Retrying in {wait_time} seconds...")
+ wait_time = (2 ** attempt) + random.uniform(0, 2)
+ logger_func(f" Retrying in {wait_time:.1f} seconds...")
time.sleep(wait_time)
else:
logger_func(f" [AllComic] ❌ All attempts to check series page failed.")
- return [] # Return empty on final failure
+ return []
if not response:
return []
@@ -44,7 +44,7 @@ def get_chapter_list(series_url, logger_func):
return []
chapter_urls = [link['href'] for link in chapter_links]
- chapter_urls.reverse() # Reverse for oldest-to-newest reading order
+ chapter_urls.reverse()
logger_func(f" [AllComic] ✅ Found {len(chapter_urls)} chapters.")
return chapter_urls
@@ -53,15 +53,13 @@ def get_chapter_list(series_url, logger_func):
logger_func(f" [AllComic] ❌ Error parsing chapters after successful connection: {e}")
return []
-def fetch_chapter_data(chapter_url, logger_func):
+def fetch_chapter_data(scraper, chapter_url, logger_func):
"""
Fetches the comic title, chapter title, and image URLs for a single chapter page.
+ Relies on a passed-in scraper session for connection.
"""
logger_func(f" [AllComic] Fetching page: {chapter_url}")
- scraper = cloudscraper.create_scraper(
- browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True}
- )
headers = {'Referer': 'https://allporncomic.com/'}
response = None
@@ -72,16 +70,23 @@ def fetch_chapter_data(chapter_url, logger_func):
response.raise_for_status()
break
except requests.RequestException as e:
+ logger_func(f" [AllComic] ⚠️ Chapter page connection attempt {attempt + 1}/{max_retries} failed: {e}")
if attempt < max_retries - 1:
- time.sleep(2 * (attempt + 1))
+ wait_time = (2 ** attempt) + random.uniform(0, 2)
+ logger_func(f" Retrying in {wait_time:.1f} seconds...")
+ time.sleep(wait_time)
else:
logger_func(f" [AllComic] ❌ All connection attempts failed for chapter: {chapter_url}")
return None, None, None
+ if not response:
+ return None, None, None
+
try:
soup = BeautifulSoup(response.text, 'html.parser')
+
+ comic_title = "Unknown Comic"
title_element = soup.find('h1', class_='post-title')
- comic_title = None
if title_element:
comic_title = title_element.text.strip()
else:
@@ -91,7 +96,7 @@ def fetch_chapter_data(chapter_url, logger_func):
comic_slug = path_parts[-2]
comic_title = comic_slug.replace('-', ' ').title()
except Exception:
- comic_title = "Unknown Comic"
+ pass
chapter_slug = chapter_url.strip('/').split('/')[-1]
chapter_title = chapter_slug.replace('-', ' ').title()
@@ -105,8 +110,8 @@ def fetch_chapter_data(chapter_url, logger_func):
if img_url:
list_of_image_urls.append(img_url)
- if not comic_title or comic_title == "Unknown Comic" or not list_of_image_urls:
- logger_func(f" [AllComic] ❌ Could not find a valid title or images on the page. Title found: '{comic_title}'")
+ if not list_of_image_urls:
+ logger_func(f" [AllComic] ❌ Could not find any images on the page.")
return None, None, None
return comic_title, chapter_title, list_of_image_urls
diff --git a/src/core/booru_client.py b/src/core/booru_client.py
index 6ddcf13..0ca317e 100644
--- a/src/core/booru_client.py
+++ b/src/core/booru_client.py
@@ -1,4 +1,3 @@
-# src/core/booru_client.py
import os
import re
diff --git a/src/core/bunkr_client.py b/src/core/bunkr_client.py
index 9480891..4339169 100644
--- a/src/core/bunkr_client.py
+++ b/src/core/bunkr_client.py
@@ -164,17 +164,34 @@ class BunkrAlbumExtractor(Extractor):
def _extract_file(self, webpage_url):
page = self.request(webpage_url).text
data_id = extr(page, 'data-file-id="', '"')
- referer = self.root_dl + "/file/" + data_id
- headers = {"Referer": referer, "Origin": self.root_dl}
+
+ # This referer is for the API call only
+ api_referer = self.root_dl + "/file/" + data_id
+ headers = {"Referer": api_referer, "Origin": self.root_dl}
data = self.request_json(self.endpoint, method="POST", headers=headers, json={"id": data_id})
+ # Get the raw file URL (no domain replacement)
file_url = decrypt_xor(data["url"], f"SECRET_KEY_{data['timestamp'] // 3600}".encode()) if data.get("encrypted") else data["url"]
+
file_name = extr(page, "
")[2]
+ # --- NEW FIX ---
+ # The download thread uses a new `requests` call, so we must
+ # explicitly pass BOTH the User-Agent and the correct Referer.
+
+ # 1. Get the User-Agent from this extractor's session
+ user_agent = self.session.headers.get("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0")
+
+ # 2. Use the original album URL as the Referer
+ download_referer = self.url
+
return {
"url": file_url,
"name": unescape(file_name),
- "_http_headers": {"Referer": referer}
+ "_http_headers": {
+ "Referer": download_referer,
+ "User-Agent": user_agent
+ }
}
class BunkrMediaExtractor(BunkrAlbumExtractor):
diff --git a/src/core/rule34video_client.py b/src/core/rule34video_client.py
new file mode 100644
index 0000000..c069ebf
--- /dev/null
+++ b/src/core/rule34video_client.py
@@ -0,0 +1,107 @@
+import cloudscraper
+from bs4 import BeautifulSoup
+import re
+import html
+
+def fetch_rule34video_data(video_url, logger_func):
+ """
+ Scrapes a rule34video.com page by specifically finding the 'Download' div,
+ then selecting the best available quality link.
+
+ Args:
+ video_url (str): The full URL to the rule34video.com page.
+ logger_func (callable): Function to use for logging progress.
+
+ Returns:
+ tuple: (video_title, final_video_url) or (None, None) on failure.
+ """
+ logger_func(f" [Rule34Video] Fetching page: {video_url}")
+ scraper = cloudscraper.create_scraper()
+
+ try:
+ main_page_response = scraper.get(video_url, timeout=20)
+ main_page_response.raise_for_status()
+
+ soup = BeautifulSoup(main_page_response.text, 'html.parser')
+
+ page_title_tag = soup.find('title')
+ video_title = page_title_tag.text.strip() if page_title_tag else "rule34video_file"
+
+ # --- START OF FINAL FIX ---
+ # 1. Find the SPECIFIC "Download" label first. This is the key.
+ download_label = soup.find('div', class_='label', string='Download')
+
+ if not download_label:
+ logger_func(" [Rule34Video] ❌ Could not find the 'Download' label. Unable to locate the correct links div.")
+ return None, None
+
+ # 2. The correct container is the parent of this label.
+ download_div = download_label.parent
+
+ # 3. Now, find the links ONLY within this correct container.
+ link_tags = download_div.find_all('a', class_='tag_item')
+ if not link_tags:
+ logger_func(" [Rule34Video] ❌ Found the 'Download' div, but no download links were inside it.")
+ return None, None
+ # --- END OF FINAL FIX ---
+
+ links_by_quality = {}
+ quality_pattern = re.compile(r'(\d+p|4k)')
+
+ for tag in link_tags:
+ href = tag.get('href')
+ if not href:
+ continue
+
+ quality = None
+ text_match = quality_pattern.search(tag.text)
+ if text_match:
+ quality = text_match.group(1)
+ else:
+ href_match = quality_pattern.search(href)
+ if href_match:
+ quality = href_match.group(1)
+
+ if quality:
+ links_by_quality[quality] = href
+
+ if not links_by_quality:
+ logger_func(" [Rule34Video] ⚠️ Could not parse specific qualities. Using first available link as a fallback.")
+ final_video_url = link_tags[0].get('href')
+ if not final_video_url:
+ logger_func(" [Rule34Video] ❌ Fallback failed: First link tag had no href attribute.")
+ return None, None
+
+ final_video_url = html.unescape(final_video_url)
+ logger_func(f" [Rule34Video] ✅ Selected first available link as fallback: {final_video_url}")
+ return video_title, final_video_url
+
+ logger_func(f" [Rule34Video] Found available qualities: {list(links_by_quality.keys())}")
+
+ final_video_url = None
+ if '1080p' in links_by_quality:
+ final_video_url = links_by_quality['1080p']
+ logger_func(" [Rule34Video] ✅ Selected preferred 1080p link.")
+ elif '720p' in links_by_quality:
+ final_video_url = links_by_quality['720p']
+ logger_func(" [Rule34Video] ✅ 1080p not found. Selected fallback 720p link.")
+ else:
+ fallback_order = ['480p', '360p']
+ for quality in fallback_order:
+ if quality in links_by_quality:
+ final_video_url = links_by_quality[quality]
+ logger_func(f" [Rule34Video] ⚠️ 1080p/720p not found. Selected best available fallback: {quality}")
+ break
+
+ if not final_video_url:
+ logger_func(" [Rule34Video] ❌ Could not find a suitable download link.")
+ return None, None
+
+ final_video_url = html.unescape(final_video_url)
+ logger_func(f" [Rule34Video] ✅ Selected direct download URL: {final_video_url}")
+
+ return video_title, final_video_url
+
+ except Exception as e:
+ logger_func(f" [Rule34Video] ❌ An error occurred: {e}")
+ return None, None
\ No newline at end of file
diff --git a/src/core/simpcity_client.py b/src/core/simpcity_client.py
index bc427be..be8a64d 100644
--- a/src/core/simpcity_client.py
+++ b/src/core/simpcity_client.py
@@ -17,8 +17,10 @@ def fetch_single_simpcity_page(url, logger_func, cookies=None, post_id=None):
try:
response = scraper.get(url, timeout=30, headers=headers, cookies=cookies)
+ final_url = response.url # Capture the final URL after any redirects
+
if response.status_code == 404:
- return None, []
+ return None, [], final_url
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
@@ -91,9 +93,9 @@ def fetch_single_simpcity_page(url, logger_func, cookies=None, post_id=None):
# We use a set to remove duplicate URLs that might be found in multiple ways
unique_jobs = list({job['url']: job for job in jobs_on_page}.values())
logger_func(f" [SimpCity] Scraper found jobs: {[job['type'] for job in unique_jobs]}")
- return album_title, unique_jobs
+ return album_title, unique_jobs, final_url
- return album_title, []
+ return album_title, [], final_url
except Exception as e:
logger_func(f" [SimpCity] ❌ Error fetching page {url}: {e}")
diff --git a/src/ui/classes/allcomic_downloader_thread.py b/src/ui/classes/allcomic_downloader_thread.py
new file mode 100644
index 0000000..00cbc2e
--- /dev/null
+++ b/src/ui/classes/allcomic_downloader_thread.py
@@ -0,0 +1,137 @@
+import os
+import threading
+import time
+from urllib.parse import urlparse
+
+import cloudscraper
+import requests
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.allcomic_client import (fetch_chapter_data as allcomic_fetch_data,
+ get_chapter_list as allcomic_get_list)
+from ...utils.file_utils import clean_folder_name
+
+
+class AllcomicDownloadThread(QThread):
+ """A dedicated QThread for handling allcomic.com downloads."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.comic_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+ self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
+
+ def _check_pause(self):
+ if self.is_cancelled: return True
+ if self.pause_event and self.pause_event.is_set():
+ self.progress_signal.emit(" Download paused...")
+ while self.pause_event.is_set():
+ if self.is_cancelled: return True
+ time.sleep(0.5)
+ self.progress_signal.emit(" Download resumed.")
+ return self.is_cancelled
+
+ def run(self):
+ grand_total_dl = 0
+ grand_total_skip = 0
+
+ # Create the scraper session ONCE for the entire job
+ scraper = cloudscraper.create_scraper(
+ browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True}
+ )
+
+ # Pass the scraper to the function
+ chapters_to_download = allcomic_get_list(scraper, self.comic_url, self.progress_signal.emit)
+
+ if not chapters_to_download:
+ chapters_to_download = [self.comic_url]
+
+ self.progress_signal.emit(f"--- Starting download of {len(chapters_to_download)} chapter(s) ---")
+
+ for chapter_idx, chapter_url in enumerate(chapters_to_download):
+ if self._check_pause(): break
+
+ 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)
+
+ if not image_urls:
+ self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
+ continue
+
+ series_folder_name = clean_folder_name(comic_title)
+ chapter_folder_name = clean_folder_name(chapter_title)
+ final_save_path = os.path.join(self.output_dir, series_folder_name, chapter_folder_name)
+
+ try:
+ os.makedirs(final_save_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving to folder: '{os.path.join(series_folder_name, chapter_folder_name)}'")
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ grand_total_skip += len(image_urls)
+ continue
+
+ total_files_in_chapter = len(image_urls)
+ self.overall_progress_signal.emit(total_files_in_chapter, 0)
+ headers = {'Referer': chapter_url}
+
+ for i, img_url in enumerate(image_urls):
+ if self._check_pause(): break
+
+ file_extension = os.path.splitext(urlparse(img_url).path)[1] or '.jpg'
+ filename = f"{i+1:03d}{file_extension}"
+ filepath = os.path.join(final_save_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({i+1}/{total_files_in_chapter}): '{filename}' already exists.")
+ grand_total_skip += 1
+ else:
+ download_successful = False
+ max_retries = 8
+ for attempt in range(max_retries):
+ if self._check_pause(): break
+ try:
+ 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)
+ response.raise_for_status()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self._check_pause(): break
+ f.write(chunk)
+
+ if self._check_pause():
+ if os.path.exists(filepath): os.remove(filepath)
+ break
+
+ download_successful = True
+ grand_total_dl += 1
+ break
+
+ except requests.RequestException as e:
+ self.progress_signal.emit(f" ⚠️ Attempt {attempt + 1} failed for '{filename}': {e}")
+ if attempt < max_retries - 1:
+ wait_time = 2 * (attempt + 1)
+ self.progress_signal.emit(f" Retrying in {wait_time} seconds...")
+ time.sleep(wait_time)
+ else:
+ self.progress_signal.emit(f" ❌ All attempts failed for '{filename}'. Skipping.")
+ grand_total_skip += 1
+
+ self.overall_progress_signal.emit(total_files_in_chapter, i + 1)
+ time.sleep(0.5) # Increased delay between images for this site
+
+ if self._check_pause(): break
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(grand_total_dl, grand_total_skip, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by AllComic thread.")
\ No newline at end of file
diff --git a/src/ui/classes/booru_downloader_thread.py b/src/ui/classes/booru_downloader_thread.py
new file mode 100644
index 0000000..fb7fc07
--- /dev/null
+++ b/src/ui/classes/booru_downloader_thread.py
@@ -0,0 +1,133 @@
+import os
+import threading
+import time
+import datetime
+import requests
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.booru_client import fetch_booru_data, BooruClientException
+from ...utils.file_utils import clean_folder_name
+
+_ff_ver = (datetime.date.today().toordinal() - 735506) // 28
+USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; "
+ f"rv:{_ff_ver}.0) Gecko/20100101 Firefox/{_ff_ver}.0")
+
+class BooruDownloadThread(QThread):
+ """A dedicated QThread for handling Danbooru and Gelbooru downloads."""
+ progress_signal = pyqtSignal(str)
+ overall_progress_signal = pyqtSignal(int, int)
+ finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
+
+ def __init__(self, url, output_dir, api_key, user_id, parent=None):
+ super().__init__(parent)
+ self.booru_url = url
+ self.output_dir = output_dir
+ self.api_key = api_key
+ self.user_id = user_id
+ self.is_cancelled = False
+ self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
+
+ def run(self):
+ download_count = 0
+ skip_count = 0
+ processed_count = 0
+ cumulative_total = 0
+
+ def logger(msg):
+ self.progress_signal.emit(str(msg))
+
+ try:
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Booru Download for: {self.booru_url}")
+
+ item_generator = fetch_booru_data(self.booru_url, self.api_key, self.user_id, logger)
+
+ download_path = self.output_dir # Default path
+ path_initialized = False
+
+ session = requests.Session()
+ session.headers["User-Agent"] = USERAGENT_FIREFOX
+
+ for item in item_generator:
+ if self.is_cancelled:
+ break
+
+ if isinstance(item, tuple) and item[0] == 'PAGE_UPDATE':
+ newly_found = item[1]
+ cumulative_total += newly_found
+ self.progress_signal.emit(f" Found {newly_found} more posts. Total so far: {cumulative_total}")
+ self.overall_progress_signal.emit(cumulative_total, processed_count)
+ continue
+
+ post_data = item
+ processed_count += 1
+
+ if not path_initialized:
+ base_folder_name = post_data.get('search_tags', 'booru_download')
+ download_path = os.path.join(self.output_dir, clean_folder_name(base_folder_name))
+ os.makedirs(download_path, exist_ok=True)
+ path_initialized = True
+
+ if self.pause_event.is_set():
+ self.progress_signal.emit(" Download paused...")
+ while self.pause_event.is_set():
+ if self.is_cancelled: break
+ time.sleep(0.5)
+ if self.is_cancelled: break
+ self.progress_signal.emit(" Download resumed.")
+
+ file_url = post_data.get('file_url')
+ if not file_url:
+ skip_count += 1
+ self.progress_signal.emit(f" -> Skip ({processed_count}/{cumulative_total}): Post ID {post_data.get('id')} has no file URL.")
+ continue
+
+ cat = post_data.get('category', 'booru')
+ post_id = post_data.get('id', 'unknown')
+ md5 = post_data.get('md5', '')
+ fname = post_data.get('filename', f"file_{post_id}")
+ ext = post_data.get('extension', 'jpg')
+
+ final_filename = f"{cat}_{post_id}_{md5 or fname}.{ext}"
+ filepath = os.path.join(download_path, final_filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({processed_count}/{cumulative_total}): '{final_filename}' already exists.")
+ skip_count += 1
+ else:
+ try:
+ self.progress_signal.emit(f" Downloading ({processed_count}/{cumulative_total}): '{final_filename}'...")
+ response = session.get(file_url, stream=True, timeout=60)
+ response.raise_for_status()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled: break
+ f.write(chunk)
+
+ if not self.is_cancelled:
+ download_count += 1
+ else:
+ if os.path.exists(filepath): os.remove(filepath)
+ skip_count += 1
+
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{final_filename}': {e}")
+ skip_count += 1
+
+ self.overall_progress_signal.emit(cumulative_total, processed_count)
+ time.sleep(0.2)
+
+ if not path_initialized:
+ self.progress_signal.emit("No posts found for the given URL/tags.")
+
+ except BooruClientException as e:
+ self.progress_signal.emit(f"❌ A Booru client error occurred: {e}")
+ except Exception as e:
+ self.progress_signal.emit(f"❌ An unexpected error occurred in Booru thread: {e}")
+ finally:
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Booru thread.")
\ No newline at end of file
diff --git a/src/ui/classes/bunkr_downloader_thread.py b/src/ui/classes/bunkr_downloader_thread.py
new file mode 100644
index 0000000..15cf7cc
--- /dev/null
+++ b/src/ui/classes/bunkr_downloader_thread.py
@@ -0,0 +1,195 @@
+import os
+import re
+import time
+import requests
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.bunkr_client import fetch_bunkr_data
+
+# Define image extensions
+IMG_EXTS = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif')
+BUNKR_IMG_THREADS = 6 # Hardcoded thread count for images
+
+class BunkrDownloadThread(QThread):
+ """A dedicated QThread for handling Bunkr downloads."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool, list)
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.bunkr_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+
+ # --- NEW: Threading members ---
+ self.lock = threading.Lock()
+ self.download_count = 0
+ self.skip_count = 0
+ self.file_index = 0 # Use a shared index for logging
+
+ class ThreadLogger:
+ def __init__(self, signal_emitter):
+ self.signal_emitter = signal_emitter
+ def info(self, msg, *args, **kwargs):
+ self.signal_emitter.emit(str(msg))
+ def error(self, msg, *args, **kwargs):
+ self.signal_emitter.emit(f"❌ ERROR: {msg}")
+ def warning(self, msg, *args, **kwargs):
+ self.signal_emitter.emit(f"⚠️ WARNING: {msg}")
+ def debug(self, msg, *args, **kwargs):
+ pass
+
+ self.logger = ThreadLogger(self.progress_signal)
+
+ def _download_file(self, file_data, total_files, album_path, is_image_task=False):
+ """
+ A thread-safe method to download a single file.
+ This function will be called by the main thread (for videos)
+ and worker threads (for images).
+ """
+
+ # Stop if a cancellation signal was received before starting
+ if self.is_cancelled:
+ return
+
+ # --- Thread-safe index for logging ---
+ with self.lock:
+ self.file_index += 1
+ current_file_num = self.file_index
+
+ try:
+ filename = file_data.get('name', 'untitled_file')
+ file_url = file_data.get('url')
+ headers = file_data.get('_http_headers')
+
+ filename = re.sub(r'[<>:"/\\|?*]', '_', filename).strip()
+ filepath = os.path.join(album_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({current_file_num}/{total_files}): '{filename}' already exists.")
+ with self.lock:
+ self.skip_count += 1
+ return
+
+ self.progress_signal.emit(f" Downloading ({current_file_num}/{total_files}): '{filename}'...")
+
+ response = requests.get(file_url, stream=True, headers=headers, timeout=60)
+ response.raise_for_status()
+
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded_size = 0
+ last_update_time = time.time()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled:
+ break
+ if chunk:
+ f.write(chunk)
+ downloaded_size += len(chunk)
+
+ # For videos/other files, send frequent progress
+ # For images, don't send progress to avoid UI flicker
+ if not is_image_task:
+ current_time = time.time()
+ if total_size > 0 and (current_time - last_update_time) > 0.5:
+ self.file_progress_signal.emit(filename, (downloaded_size, total_size))
+ last_update_time = current_time
+
+ if self.is_cancelled:
+ self.progress_signal.emit(f" Download cancelled for '{filename}'.")
+ if os.path.exists(filepath): os.remove(filepath)
+ return
+
+ if total_size > 0:
+ self.file_progress_signal.emit(filename, (total_size, total_size))
+
+ with self.lock:
+ self.download_count += 1
+
+ except requests.exceptions.RequestException as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ with self.lock:
+ self.skip_count += 1
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ An unexpected error occurred with '{filename}': {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ with self.lock:
+ self.skip_count += 1
+
+ def run(self):
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Bunkr Download for: {self.bunkr_url}")
+
+ album_name, files_to_download = fetch_bunkr_data(self.bunkr_url, self.logger)
+
+ if not files_to_download:
+ self.progress_signal.emit("❌ Failed to extract file information from Bunkr. Aborting.")
+ self.finished_signal.emit(0, 0, self.is_cancelled, [])
+ return
+
+ album_path = os.path.join(self.output_dir, album_name)
+ try:
+ os.makedirs(album_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving to folder: '{album_path}'")
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ self.finished_signal.emit(0, len(files_to_download), self.is_cancelled, [])
+ return
+
+ total_files = len(files_to_download)
+
+ # --- NEW: Separate files into images and others ---
+ image_files = []
+ other_files = []
+ for f in files_to_download:
+ name = f.get('name', '').lower()
+ if name.endswith(IMG_EXTS):
+ image_files.append(f)
+ else:
+ other_files.append(f)
+
+ self.progress_signal.emit(f" Found {len(image_files)} images and {len(other_files)} other files.")
+
+ # --- 1. Process videos and other files sequentially (one by one) ---
+ if other_files:
+ self.progress_signal.emit(f" Downloading {len(other_files)} videos/other files sequentially...")
+ for file_data in other_files:
+ if self.is_cancelled:
+ break
+ # Call the new download helper method
+ self._download_file(file_data, total_files, album_path, is_image_task=False)
+
+ # --- 2. Process images concurrently using a fixed 6-thread pool ---
+ if image_files and not self.is_cancelled:
+ self.progress_signal.emit(f" Downloading {len(image_files)} images concurrently ({BUNKR_IMG_THREADS} threads)...")
+ with ThreadPoolExecutor(max_workers=BUNKR_IMG_THREADS, thread_name_prefix='BunkrImg') as executor:
+
+ # Submit all image download tasks
+ futures = {executor.submit(self._download_file, file_data, total_files, album_path, is_image_task=True): file_data for file_data in image_files}
+
+ try:
+ # Wait for tasks to complete, but check for cancellation
+ for future in futures:
+ if self.is_cancelled:
+ future.cancel() # Try to cancel running/pending tasks
+ else:
+ future.result() # Wait for the task to finish (or raise exception)
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ A thread pool error occurred: {e}")
+
+ if self.is_cancelled:
+ self.progress_signal.emit(" Download cancelled by user.")
+ # Update skip count to reflect all non-downloaded files
+ self.skip_count = total_files - self.download_count
+
+ self.file_progress_signal.emit("", None) # Clear file progress
+ self.finished_signal.emit(self.download_count, self.skip_count, self.is_cancelled, [])
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Bunkr thread.")
\ No newline at end of file
diff --git a/src/ui/classes/discord_downloader_thread.py b/src/ui/classes/discord_downloader_thread.py
new file mode 100644
index 0000000..8853881
--- /dev/null
+++ b/src/ui/classes/discord_downloader_thread.py
@@ -0,0 +1,189 @@
+import os
+import time
+import datetime
+import requests
+from PyQt5.QtCore import QThread, pyqtSignal
+
+# Assuming discord_pdf_generator is in the dialogs folder, sibling to the classes folder
+from ..dialogs.discord_pdf_generator import create_pdf_from_discord_messages
+
+# This constant is needed for the thread to function independently
+_ff_ver = (datetime.date.today().toordinal() - 735506) // 28
+USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; "
+ f"rv:{_ff_ver}.0) Gecko/20100101 Firefox/{_ff_ver}.0")
+
+class DiscordDownloadThread(QThread):
+ """A dedicated QThread for handling all official Discord downloads."""
+ progress_signal = pyqtSignal(str)
+ progress_label_signal = pyqtSignal(str)
+ finished_signal = pyqtSignal(int, int, bool, list)
+
+ def __init__(self, mode, session, token, output_dir, server_id, channel_id, url, app_base_dir, limit=None, parent=None):
+ super().__init__(parent)
+ self.mode = mode
+ self.session = session
+ self.token = token
+ self.output_dir = output_dir
+ self.server_id = server_id
+ self.channel_id = channel_id
+ self.api_url = url
+ self.message_limit = limit
+ self.app_base_dir = app_base_dir # Path to app's base directory
+
+ self.is_cancelled = False
+ self.is_paused = False
+
+ def run(self):
+ if self.mode == 'pdf':
+ self._run_pdf_creation()
+ else:
+ self._run_file_download()
+
+ def cancel(self):
+ self.progress_signal.emit(" Cancellation signal received by Discord thread.")
+ self.is_cancelled = True
+
+ def pause(self):
+ self.progress_signal.emit(" Pausing Discord download...")
+ self.is_paused = True
+
+ def resume(self):
+ self.progress_signal.emit(" Resuming Discord download...")
+ self.is_paused = False
+
+ def _check_events(self):
+ if self.is_cancelled:
+ return True
+ while self.is_paused:
+ time.sleep(0.5)
+ if self.is_cancelled:
+ return True
+ return False
+
+ def _fetch_all_messages(self):
+ all_messages = []
+ last_message_id = None
+ headers = {'Authorization': self.token, 'User-Agent': USERAGENT_FIREFOX}
+
+ while True:
+ if self._check_events(): break
+
+ endpoint = f"/channels/{self.channel_id}/messages?limit=100"
+ if last_message_id:
+ endpoint += f"&before={last_message_id}"
+
+ try:
+ resp = self.session.get(f"https://discord.com/api/v10{endpoint}", headers=headers, timeout=30)
+ resp.raise_for_status()
+ message_batch = resp.json()
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Error fetching message batch: {e}")
+ break
+
+ if not message_batch:
+ break
+
+ all_messages.extend(message_batch)
+
+ if self.message_limit and len(all_messages) >= self.message_limit:
+ self.progress_signal.emit(f" Reached message limit of {self.message_limit}. Halting fetch.")
+ all_messages = all_messages[:self.message_limit]
+ break
+
+ last_message_id = message_batch[-1]['id']
+ self.progress_label_signal.emit(f"Fetched {len(all_messages)} messages...")
+ time.sleep(1) # API Rate Limiting
+
+ return all_messages
+
+ def _run_pdf_creation(self):
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Discord PDF export for: {self.api_url}")
+ self.progress_label_signal.emit("Fetching messages...")
+
+ all_messages = self._fetch_all_messages()
+
+ if self.is_cancelled:
+ self.finished_signal.emit(0, 0, True, [])
+ return
+
+ self.progress_label_signal.emit(f"Collected {len(all_messages)} total messages. Generating PDF...")
+ all_messages.reverse()
+
+ font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
+ output_filepath = os.path.join(self.output_dir, f"discord_{self.server_id}_{self.channel_id or 'server'}.pdf")
+
+ success = create_pdf_from_discord_messages(
+ all_messages, self.server_id, self.channel_id,
+ output_filepath, font_path, logger=self.progress_signal.emit,
+ cancellation_event=self, pause_event=self
+ )
+
+ if success:
+ self.progress_label_signal.emit(f"✅ PDF export complete!")
+ elif not self.is_cancelled:
+ self.progress_label_signal.emit(f"❌ PDF export failed. Check log for details.")
+
+ self.finished_signal.emit(0, len(all_messages), self.is_cancelled, [])
+
+ def _run_file_download(self):
+ download_count = 0
+ skip_count = 0
+ try:
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Discord download for channel: {self.channel_id}")
+ self.progress_label_signal.emit("Fetching messages...")
+ all_messages = self._fetch_all_messages()
+
+ if self.is_cancelled:
+ self.finished_signal.emit(0, 0, True, [])
+ return
+
+ self.progress_label_signal.emit(f"Collected {len(all_messages)} messages. Starting downloads...")
+ total_attachments = sum(len(m.get('attachments', [])) for m in all_messages)
+
+ for message in reversed(all_messages):
+ if self._check_events(): break
+ for attachment in message.get('attachments', []):
+ if self._check_events(): break
+
+ file_url = attachment['url']
+ original_filename = attachment['filename']
+ filepath = os.path.join(self.output_dir, original_filename)
+ filename_to_use = original_filename
+
+ counter = 1
+ base_name, extension = os.path.splitext(original_filename)
+ while os.path.exists(filepath):
+ filename_to_use = f"{base_name} ({counter}){extension}"
+ filepath = os.path.join(self.output_dir, filename_to_use)
+ counter += 1
+
+ if filename_to_use != original_filename:
+ self.progress_signal.emit(f" -> Duplicate name '{original_filename}'. Saving as '{filename_to_use}'.")
+
+ try:
+ self.progress_signal.emit(f" Downloading ({download_count+1}/{total_attachments}): '{filename_to_use}'...")
+ response = requests.get(file_url, stream=True, timeout=60)
+ response.raise_for_status()
+
+ download_cancelled_mid_file = False
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self._check_events():
+ download_cancelled_mid_file = True
+ break
+ f.write(chunk)
+
+ if download_cancelled_mid_file:
+ self.progress_signal.emit(f" Download cancelled for '{filename_to_use}'. Deleting partial file.")
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ continue
+
+ download_count += 1
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename_to_use}': {e}")
+ skip_count += 1
+ finally:
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled, [])
\ No newline at end of file
diff --git a/src/ui/classes/downloader_factory.py b/src/ui/classes/downloader_factory.py
new file mode 100644
index 0000000..6c68bf8
--- /dev/null
+++ b/src/ui/classes/downloader_factory.py
@@ -0,0 +1,133 @@
+import re
+import requests
+from urllib.parse import urlparse
+
+from ...utils.network_utils import prepare_cookies_for_request
+from ...utils.file_utils import clean_folder_name
+from .allcomic_downloader_thread import AllcomicDownloadThread
+from .booru_downloader_thread import BooruDownloadThread
+from .bunkr_downloader_thread import BunkrDownloadThread
+from .discord_downloader_thread import DiscordDownloadThread
+from .drive_downloader_thread import DriveDownloadThread
+from .erome_downloader_thread import EromeDownloadThread
+from .external_link_downloader_thread import ExternalLinkDownloadThread
+from .fap_nation_downloader_thread import FapNationDownloadThread
+from .hentai2read_downloader_thread import Hentai2readDownloadThread
+from .mangadex_downloader_thread import MangaDexDownloadThread
+from .nhentai_downloader_thread import NhentaiDownloadThread
+from .pixeldrain_downloader_thread import PixeldrainDownloadThread
+from .saint2_downloader_thread import Saint2DownloadThread
+from .simp_city_downloader_thread import SimpCityDownloadThread
+from .toonily_downloader_thread import ToonilyDownloadThread
+from .rule34video_downloader_thread import Rule34VideoDownloadThread
+
+
+def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
+ """
+ Factory function to create and configure the correct QThread for a given URL.
+ Returns a configured QThread instance or None if no special handler is found.
+ """
+
+ # Handler for Booru sites (Danbooru, Gelbooru)
+ if service in ['danbooru', 'gelbooru']:
+ api_key = main_app.api_key_input.text().strip()
+ user_id = main_app.user_id_input.text().strip()
+ return BooruDownloadThread(
+ url=api_url, output_dir=effective_output_dir_for_run,
+ api_key=api_key, user_id=user_id, parent=main_app
+ )
+
+ # Handler for cloud storage sites (Mega, GDrive, etc.)
+ platform = None
+ if 'mega.nz' in api_url or 'mega.io' in api_url: platform = 'mega'
+ elif 'drive.google.com' in api_url: platform = 'gdrive'
+ elif 'dropbox.com' in api_url: platform = 'dropbox'
+ elif 'gofile.io' in api_url: platform = 'gofile'
+ if platform:
+ use_post_subfolder = main_app.use_subfolder_per_post_checkbox.isChecked()
+ return DriveDownloadThread(
+ api_url, effective_output_dir_for_run, platform, use_post_subfolder,
+ main_app.cancellation_event, main_app.pause_event, main_app.log_signal.emit
+ )
+
+ # Handler for Erome
+ if 'erome.com' in api_url:
+ return EromeDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for MangaDex
+ if 'mangadex.org' in api_url:
+ return MangaDexDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for Saint2
+ is_saint2_url = 'saint2.su' in api_url or 'saint2.pk' in api_url
+ if is_saint2_url and api_url.strip().lower() != 'saint2.su': # Exclude batch mode trigger
+ return Saint2DownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for SimpCity
+ if service == 'simpcity':
+ cookies = prepare_cookies_for_request(
+ use_cookie_flag=True, cookie_text_input=main_app.cookie_text_input.text(),
+ selected_cookie_file_path=main_app.selected_cookie_filepath,
+ app_base_dir=main_app.app_base_dir, logger_func=main_app.log_signal.emit,
+ target_domain='simpcity.cr'
+ )
+ if not cookies:
+ # The main app will handle the error dialog
+ return "COOKIE_ERROR"
+ return SimpCityDownloadThread(api_url, id2, effective_output_dir_for_run, cookies, main_app)
+
+ if service == 'rule34video':
+ main_app.log_signal.emit("ℹ️ Rule34Video.com URL detected. Starting dedicated downloader.")
+ # id1 contains the video_id from extract_post_info
+ return Rule34VideoDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for official Discord URLs
+ if 'discord.com' in api_url and service == 'discord':
+ token = main_app.remove_from_filename_input.text().strip()
+ limit_text = main_app.discord_message_limit_input.text().strip()
+ message_limit = int(limit_text) if limit_text else None
+ mode = 'pdf' if main_app.discord_download_scope == 'messages' else 'files'
+ return DiscordDownloadThread(
+ mode=mode, session=requests.Session(), token=token, output_dir=effective_output_dir_for_run,
+ server_id=id1, channel_id=id2, url=api_url, app_base_dir=main_app.app_base_dir,
+ limit=message_limit, parent=main_app
+ )
+
+ # Handler for Allcomic/Allporncomic
+ if 'allcomic.com' in api_url or 'allporncomic.com' in api_url:
+ return AllcomicDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for Hentai2Read
+ if 'hentai2read.com' in api_url:
+ return Hentai2readDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for Fap-Nation
+ if 'fap-nation.com' in api_url or 'fap-nation.org' in api_url:
+ use_post_subfolder = main_app.use_subfolder_per_post_checkbox.isChecked()
+ return FapNationDownloadThread(
+ api_url, effective_output_dir_for_run, use_post_subfolder,
+ main_app.pause_event, main_app.cancellation_event, main_app.actual_gui_signals, main_app
+ )
+
+ # Handler for Pixeldrain
+ if 'pixeldrain.com' in api_url:
+ return PixeldrainDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for nHentai
+ if service == 'nhentai':
+ from ...core.nhentai_client import fetch_nhentai_gallery
+ gallery_data = fetch_nhentai_gallery(id1, main_app.log_signal.emit)
+ if not gallery_data:
+ return "FETCH_ERROR" # Sentinel value for fetch failure
+ return NhentaiDownloadThread(gallery_data, effective_output_dir_for_run, main_app)
+
+ # Handler for Toonily
+ if 'toonily.com' in api_url:
+ return ToonilyDownloadThread(api_url, effective_output_dir_for_run, main_app)
+
+ # Handler for Bunkr
+ if service == 'bunkr':
+ return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app)
+
+ # If no special handler matched, return None
+ return None
\ No newline at end of file
diff --git a/src/ui/classes/drive_downloader_thread.py b/src/ui/classes/drive_downloader_thread.py
new file mode 100644
index 0000000..9d4bcaf
--- /dev/null
+++ b/src/ui/classes/drive_downloader_thread.py
@@ -0,0 +1,77 @@
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...services.drive_downloader import (
+ download_dropbox_file,
+ download_gdrive_file,
+ download_gofile_folder,
+ download_mega_file as drive_download_mega_file,
+)
+
+
+class DriveDownloadThread(QThread):
+ """A dedicated QThread for handling direct Mega, GDrive, and Dropbox links."""
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool, list)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, url, output_dir, platform, use_post_subfolder, cancellation_event, pause_event, logger_func, parent=None):
+ super().__init__(parent)
+ self.drive_url = url
+ self.output_dir = output_dir
+ self.platform = platform
+ self.use_post_subfolder = use_post_subfolder
+ self.is_cancelled = False
+ self.cancellation_event = cancellation_event
+ self.pause_event = pause_event
+ self.logger_func = logger_func
+
+ def run(self):
+ self.logger_func("=" * 40)
+ self.logger_func(f"🚀 Starting direct {self.platform.capitalize()} Download for: {self.drive_url}")
+
+ try:
+ if self.platform == 'mega':
+ drive_download_mega_file(
+ self.drive_url, self.output_dir,
+ logger_func=self.logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ overall_progress_callback=self.overall_progress_signal.emit,
+ cancellation_event=self.cancellation_event,
+ pause_event=self.pause_event
+ )
+ elif self.platform == 'gdrive':
+ download_gdrive_file(
+ self.drive_url, self.output_dir,
+ logger_func=self.logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ overall_progress_callback=self.overall_progress_signal.emit,
+ use_post_subfolder=self.use_post_subfolder,
+ post_title="Google Drive Download"
+ )
+ elif self.platform == 'dropbox':
+ download_dropbox_file(
+ self.drive_url, self.output_dir,
+ logger_func=self.logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ use_post_subfolder=self.use_post_subfolder,
+ post_title="Dropbox Download"
+ )
+ elif self.platform == 'gofile':
+ download_gofile_folder(
+ self.drive_url, self.output_dir,
+ logger_func=self.logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ overall_progress_callback=self.overall_progress_signal.emit
+ )
+
+ self.finished_signal.emit(1, 0, self.is_cancelled, [])
+
+ except Exception as e:
+ self.logger_func(f"❌ An unexpected error occurred in DriveDownloadThread: {e}")
+ self.finished_signal.emit(0, 1, self.is_cancelled, [])
+
+ def cancel(self):
+ self.is_cancelled = True
+ if self.cancellation_event:
+ self.cancellation_event.set()
+ self.logger_func(f" Cancellation signal received by {self.platform.capitalize()} thread.")
\ No newline at end of file
diff --git a/src/ui/classes/erome_downloader_thread.py b/src/ui/classes/erome_downloader_thread.py
new file mode 100644
index 0000000..f7ef0bc
--- /dev/null
+++ b/src/ui/classes/erome_downloader_thread.py
@@ -0,0 +1,106 @@
+import os
+import time
+import requests
+import cloudscraper
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.erome_client import fetch_erome_data
+
+class EromeDownloadThread(QThread):
+ """A dedicated QThread for handling erome.com downloads."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.erome_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+
+ def run(self):
+ download_count = 0
+ skip_count = 0
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Erome.com Download for: {self.erome_url}")
+
+ album_name, files_to_download = fetch_erome_data(self.erome_url, self.progress_signal.emit)
+
+ if not files_to_download:
+ self.progress_signal.emit("❌ Failed to extract file information from Erome. Aborting.")
+ self.finished_signal.emit(0, 0, self.is_cancelled)
+ return
+
+ album_path = os.path.join(self.output_dir, album_name)
+ try:
+ os.makedirs(album_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving to folder: '{album_path}'")
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ self.finished_signal.emit(0, len(files_to_download), self.is_cancelled)
+ return
+
+ total_files = len(files_to_download)
+ session = cloudscraper.create_scraper()
+
+ for i, file_data in enumerate(files_to_download):
+ if self.is_cancelled:
+ self.progress_signal.emit(" Download cancelled by user.")
+ skip_count = total_files - download_count
+ break
+
+ filename = file_data.get('filename', f'untitled_{i+1}.mp4')
+ file_url = file_data.get('url')
+ headers = file_data.get('headers')
+ filepath = os.path.join(album_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
+ skip_count += 1
+ continue
+
+ self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
+
+ try:
+ response = session.get(file_url, stream=True, headers=headers, timeout=60)
+ response.raise_for_status()
+
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded_size = 0
+ last_update_time = time.time()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled:
+ break
+ if chunk:
+ f.write(chunk)
+ downloaded_size += len(chunk)
+ current_time = time.time()
+ if total_size > 0 and (current_time - last_update_time) > 0.5:
+ self.file_progress_signal.emit(filename, (downloaded_size, total_size))
+ last_update_time = current_time
+
+ if self.is_cancelled:
+ if os.path.exists(filepath): os.remove(filepath)
+ continue
+
+ if total_size > 0:
+ self.file_progress_signal.emit(filename, (total_size, total_size))
+
+ download_count += 1
+ except requests.exceptions.RequestException as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ skip_count += 1
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ An unexpected error occurred with '{filename}': {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ skip_count += 1
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Erome thread.")
\ No newline at end of file
diff --git a/src/ui/classes/external_link_downloader_thread.py b/src/ui/classes/external_link_downloader_thread.py
new file mode 100644
index 0000000..83bd1fd
--- /dev/null
+++ b/src/ui/classes/external_link_downloader_thread.py
@@ -0,0 +1,86 @@
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...services.drive_downloader import (
+ download_dropbox_file,
+ download_gdrive_file,
+ download_mega_file as drive_download_mega_file,
+)
+
+
+class ExternalLinkDownloadThread(QThread):
+ """A QThread to handle downloading multiple external links sequentially."""
+ progress_signal = pyqtSignal(str)
+ file_complete_signal = pyqtSignal(str, bool)
+ finished_signal = pyqtSignal()
+ overall_progress_signal = pyqtSignal(int, int)
+ file_progress_signal = pyqtSignal(str, object)
+
+ def __init__(self, tasks_to_download, download_base_path, parent_logger_func, parent=None, use_post_subfolder=False):
+ super().__init__(parent)
+ self.tasks = tasks_to_download
+ self.download_base_path = download_base_path
+ self.parent_logger_func = parent_logger_func
+ self.is_cancelled = False
+ self.use_post_subfolder = use_post_subfolder
+
+ def run(self):
+ total_tasks = len(self.tasks)
+ self.progress_signal.emit(f"ℹ️ Starting external link download thread for {total_tasks} link(s).")
+ self.overall_progress_signal.emit(total_tasks, 0)
+
+ for i, task_info in enumerate(self.tasks):
+ if self.is_cancelled:
+ self.progress_signal.emit("External link download cancelled by user.")
+ break
+
+ self.overall_progress_signal.emit(total_tasks, i + 1)
+
+ platform = task_info.get('platform', 'unknown').lower()
+ full_url = task_info['url']
+ post_title = task_info['title']
+
+ self.progress_signal.emit(f"Download ({i + 1}/{total_tasks}): Starting '{post_title}' ({platform.upper()}) from {full_url}")
+
+ try:
+ if platform == 'mega':
+ drive_download_mega_file(
+ full_url,
+ self.download_base_path,
+ logger_func=self.parent_logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ overall_progress_callback=self.overall_progress_signal.emit
+ )
+ elif platform == 'google drive':
+ download_gdrive_file(
+ full_url,
+ self.download_base_path,
+ logger_func=self.parent_logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ overall_progress_callback=self.overall_progress_signal.emit,
+ use_post_subfolder=self.use_post_subfolder,
+ post_title=post_title
+ )
+ elif platform == 'dropbox':
+ download_dropbox_file(
+ full_url,
+ self.download_base_path,
+ logger_func=self.parent_logger_func,
+ progress_callback_func=self.file_progress_signal.emit,
+ use_post_subfolder=self.use_post_subfolder,
+ post_title=post_title
+ )
+ else:
+ self.progress_signal.emit(f"⚠️ Unsupported platform '{platform}' for link: {full_url}")
+ self.file_complete_signal.emit(full_url, False)
+ continue
+ self.file_complete_signal.emit(full_url, True)
+ except Exception as e:
+ self.progress_signal.emit(f"❌ Error downloading ({platform.upper()}) link '{full_url}': {e}")
+ self.file_complete_signal.emit(full_url, False)
+
+ self.finished_signal.emit()
+
+ def cancel(self):
+ """Sets the cancellation flag to stop the thread gracefully."""
+ self.progress_signal.emit(" [External Links] Cancellation signal received by thread.")
+ self.is_cancelled = True
\ No newline at end of file
diff --git a/src/ui/classes/fap_nation_downloader_thread.py b/src/ui/classes/fap_nation_downloader_thread.py
new file mode 100644
index 0000000..b6b27f3
--- /dev/null
+++ b/src/ui/classes/fap_nation_downloader_thread.py
@@ -0,0 +1,162 @@
+import os
+import sys
+import re
+import threading
+import time
+from PyQt5.QtCore import QThread, pyqtSignal, QProcess
+import cloudscraper
+
+from ...core.fap_nation_client import fetch_fap_nation_data
+from ...services.multipart_downloader import download_file_in_parts
+
+class FapNationDownloadThread(QThread):
+ """
+ A dedicated QThread for Fap-Nation that uses a hybrid approach, choosing
+ between yt-dlp for HLS streams and a multipart downloader for direct links.
+ """
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, url, output_dir, use_post_subfolder, pause_event, cancellation_event, gui_signals, parent=None):
+ super().__init__(parent)
+ self.album_url = url
+ self.output_dir = output_dir
+ self.use_post_subfolder = use_post_subfolder
+ self.is_cancelled = False
+ self.process = None
+ self.current_filename = "Unknown File"
+ self.album_name = "fap-nation_album"
+ self.pause_event = pause_event
+ self.cancellation_event = cancellation_event
+ self.gui_signals = gui_signals
+ self._is_finished = False
+
+ self.process = QProcess(self)
+ self.process.readyReadStandardOutput.connect(self.handle_ytdlp_output)
+
+ def run(self):
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Fap-Nation Download for: {self.album_url}")
+
+ self.album_name, files_to_download = fetch_fap_nation_data(self.album_url, self.progress_signal.emit)
+
+ if self.is_cancelled or not files_to_download:
+ self.progress_signal.emit("❌ Failed to extract file information. Aborting.")
+ self.finished_signal.emit(0, 1, self.is_cancelled)
+ return
+
+ self.overall_progress_signal.emit(1, 0)
+
+ save_path = self.output_dir
+ if self.use_post_subfolder:
+ save_path = os.path.join(self.output_dir, self.album_name)
+ self.progress_signal.emit(f" Subfolder per Post is ON. Saving to: '{self.album_name}'")
+ os.makedirs(save_path, exist_ok=True)
+
+ file_data = files_to_download[0]
+ self.current_filename = file_data.get('filename')
+ download_url = file_data.get('url')
+ link_type = file_data.get('type')
+ filepath = os.path.join(save_path, self.current_filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip: '{self.current_filename}' already exists.")
+ self.overall_progress_signal.emit(1, 1)
+ self.finished_signal.emit(0, 1, self.is_cancelled)
+ return
+
+ if link_type == 'hls':
+ self.download_with_ytdlp(filepath, download_url)
+ elif link_type == 'direct':
+ self.download_with_multipart(filepath, download_url)
+ else:
+ self.progress_signal.emit(f" ❌ Unknown link type '{link_type}'. Aborting.")
+ self._on_ytdlp_finished(-1)
+
+ def download_with_ytdlp(self, filepath, playlist_url):
+ self.progress_signal.emit(f" Downloading (HLS Stream): '{self.current_filename}' using yt-dlp...")
+ try:
+ if getattr(sys, 'frozen', False):
+ base_path = sys._MEIPASS
+ ytdlp_path = os.path.join(base_path, "yt-dlp.exe")
+ else:
+ ytdlp_path = "yt-dlp.exe"
+
+ if not os.path.exists(ytdlp_path):
+ self.progress_signal.emit(f" ❌ ERROR: yt-dlp.exe not found at '{ytdlp_path}'.")
+ self._on_ytdlp_finished(-1)
+ return
+
+ command = [ytdlp_path, '--no-warnings', '--progress', '--output', filepath, '--merge-output-format', 'mp4', playlist_url]
+
+ self.process.start(command[0], command[1:])
+ self.process.waitForFinished(-1)
+ self._on_ytdlp_finished(self.process.exitCode())
+
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Failed to start yt-dlp: {e}")
+ self._on_ytdlp_finished(-1)
+
+ def download_with_multipart(self, filepath, direct_url):
+ self.progress_signal.emit(f" Downloading (Direct Link): '{self.current_filename}' using multipart downloader...")
+ try:
+ session = cloudscraper.create_scraper()
+ head_response = session.head(direct_url, allow_redirects=True, timeout=20)
+ head_response.raise_for_status()
+ total_size = int(head_response.headers.get('content-length', 0))
+
+ success, _, _, _ = download_file_in_parts(
+ file_url=direct_url, save_path=filepath, total_size=total_size, num_parts=5,
+ headers=session.headers, api_original_filename=self.current_filename,
+ emitter_for_multipart=self.gui_signals,
+ cookies_for_chunk_session=session.cookies,
+ cancellation_event=self.cancellation_event,
+ skip_event=None, logger_func=self.progress_signal.emit, pause_event=self.pause_event
+ )
+ self._on_ytdlp_finished(0 if success else 1)
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Multipart download failed: {e}")
+ self._on_ytdlp_finished(1)
+
+ def handle_ytdlp_output(self):
+ if not self.process:
+ return
+
+ output = self.process.readAllStandardOutput().data().decode('utf-8', errors='ignore')
+ for line in reversed(output.strip().splitlines()):
+ line = line.strip()
+ progress_match = re.search(r'\[download\]\s+([\d.]+)%\s+of\s+~?\s*([\d.]+\w+B)', line)
+ if progress_match:
+ percent, size = progress_match.groups()
+ self.file_progress_signal.emit("yt-dlp:", f"{percent}% of {size}")
+ break
+
+ def _on_ytdlp_finished(self, exit_code):
+ if self._is_finished:
+ return
+ self._is_finished = True
+
+ download_count, skip_count = 0, 0
+
+ if self.is_cancelled:
+ self.progress_signal.emit(f" Download of '{self.current_filename}' was cancelled.")
+ skip_count = 1
+ elif exit_code == 0:
+ self.progress_signal.emit(f" ✅ Download process finished successfully for '{self.current_filename}'.")
+ download_count = 1
+ else:
+ self.progress_signal.emit(f" ❌ Download process exited with an error (Code: {exit_code}) for '{self.current_filename}'.")
+ skip_count = 1
+
+ self.overall_progress_signal.emit(1, 1)
+ self.process = None
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.cancellation_event.set()
+ if self.process and self.process.state() == QProcess.Running:
+ self.progress_signal.emit(" Cancellation signal received, terminating yt-dlp process.")
+ self.process.kill()
\ No newline at end of file
diff --git a/src/ui/classes/hentai2read_downloader_thread.py b/src/ui/classes/hentai2read_downloader_thread.py
new file mode 100644
index 0000000..93eef0d
--- /dev/null
+++ b/src/ui/classes/hentai2read_downloader_thread.py
@@ -0,0 +1,51 @@
+import threading
+import time
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.Hentai2read_client import run_hentai2read_download as h2r_run_download
+
+
+class Hentai2readDownloadThread(QThread):
+ """
+ A dedicated QThread that calls the self-contained Hentai2Read client to
+ perform scraping and downloading.
+ """
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.start_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+ self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
+
+ def _check_pause(self):
+ """Helper to handle pausing and cancellation events."""
+ if self.is_cancelled: return True
+ if self.pause_event and self.pause_event.is_set():
+ self.progress_signal.emit(" Download paused...")
+ while self.pause_event.is_set():
+ if self.is_cancelled: return True
+ time.sleep(0.5)
+ self.progress_signal.emit(" Download resumed.")
+ return self.is_cancelled
+
+ def run(self):
+ """
+ Executes the main download logic by calling the dedicated client function.
+ """
+ downloaded, skipped = h2r_run_download(
+ start_url=self.start_url,
+ output_dir=self.output_dir,
+ progress_callback=self.progress_signal.emit,
+ overall_progress_callback=self.overall_progress_signal.emit,
+ check_pause_func=self._check_pause
+ )
+
+ self.finished_signal.emit(downloaded, skipped, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
\ No newline at end of file
diff --git a/src/ui/classes/mangadex_downloader_thread.py b/src/ui/classes/mangadex_downloader_thread.py
new file mode 100644
index 0000000..15a0102
--- /dev/null
+++ b/src/ui/classes/mangadex_downloader_thread.py
@@ -0,0 +1,45 @@
+import threading
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.mangadex_client import fetch_mangadex_data
+
+
+class MangaDexDownloadThread(QThread):
+ """A wrapper QThread for running the MangaDex client function."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.start_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+ self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
+ self.cancellation_event = parent.cancellation_event if hasattr(parent, 'cancellation_event') else threading.Event()
+
+ def run(self):
+ downloaded = 0
+ skipped = 0
+ try:
+ downloaded, skipped = fetch_mangadex_data(
+ self.start_url,
+ self.output_dir,
+ logger_func=self.progress_signal.emit,
+ file_progress_callback=self.file_progress_signal,
+ overall_progress_callback=self.overall_progress_signal,
+ pause_event=self.pause_event,
+ cancellation_event=self.cancellation_event
+ )
+ except Exception as e:
+ self.progress_signal.emit(f"❌ A critical error occurred in the MangaDex thread: {e}")
+ skipped = 1 # Mark as skipped if there was a critical failure
+ finally:
+ self.finished_signal.emit(downloaded, skipped, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ if self.cancellation_event:
+ self.cancellation_event.set()
+ self.progress_signal.emit(" Cancellation signal received by MangaDex thread.")
\ No newline at end of file
diff --git a/src/ui/classes/nhentai_downloader_thread.py b/src/ui/classes/nhentai_downloader_thread.py
new file mode 100644
index 0000000..6056fa1
--- /dev/null
+++ b/src/ui/classes/nhentai_downloader_thread.py
@@ -0,0 +1,105 @@
+import os
+import time
+import cloudscraper
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...utils.file_utils import clean_folder_name
+
+
+class NhentaiDownloadThread(QThread):
+ progress_signal = pyqtSignal(str)
+ finished_signal = pyqtSignal(int, int, bool)
+
+ IMAGE_SERVERS = [
+ "https://i.nhentai.net", "https://i2.nhentai.net", "https://i3.nhentai.net",
+ "https://i5.nhentai.net", "https://i7.nhentai.net"
+ ]
+
+ EXTENSION_MAP = {'j': 'jpg', 'p': 'png', 'g': 'gif', 'w': 'webp' }
+
+ def __init__(self, gallery_data, output_dir, parent=None):
+ super().__init__(parent)
+ self.gallery_data = gallery_data
+ self.output_dir = output_dir
+ self.is_cancelled = False
+
+ def run(self):
+ title = self.gallery_data.get("title", {}).get("english", f"gallery_{self.gallery_data.get('id')}")
+ gallery_id = self.gallery_data.get("id")
+ media_id = self.gallery_data.get("media_id")
+ pages_info = self.gallery_data.get("pages", [])
+
+ folder_name = clean_folder_name(title)
+ gallery_path = os.path.join(self.output_dir, folder_name)
+
+ try:
+ os.makedirs(gallery_path, exist_ok=True)
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ self.finished_signal.emit(0, len(pages_info), False)
+ return
+
+ self.progress_signal.emit(f"⬇️ Downloading '{title}' to folder '{folder_name}'...")
+
+ scraper = cloudscraper.create_scraper()
+ download_count = 0
+ skip_count = 0
+
+ for i, page_data in enumerate(pages_info):
+ if self.is_cancelled:
+ break
+
+ page_num = i + 1
+
+ ext_char = page_data.get('t', 'j')
+ 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):
+ self.progress_signal.emit(f" -> Skip (Exists): {local_filename}")
+ skip_count += 1
+ continue
+
+ download_successful = False
+ for server in self.IMAGE_SERVERS:
+ if self.is_cancelled:
+ break
+
+ full_url = f"{server}{relative_path}"
+ try:
+ self.progress_signal.emit(f" Downloading page {page_num}/{len(pages_info)} from {server} ...")
+
+ 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',
+ 'Referer': f'https://nhentai.net/g/{gallery_id}/'
+ }
+
+ response = scraper.get(full_url, headers=headers, timeout=60, stream=True)
+
+ if response.status_code == 200:
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+ download_count += 1
+ download_successful = True
+ break
+ else:
+ self.progress_signal.emit(f" -> {server} returned status {response.status_code}. Trying next server...")
+
+ except Exception as e:
+ self.progress_signal.emit(f" -> {server} failed to connect or timed out: {e}. Trying next server...")
+
+ if not download_successful:
+ self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.")
+ skip_count += 1
+
+ time.sleep(0.5)
+
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
\ No newline at end of file
diff --git a/src/ui/classes/pixeldrain_downloader_thread.py b/src/ui/classes/pixeldrain_downloader_thread.py
new file mode 100644
index 0000000..01ffb0f
--- /dev/null
+++ b/src/ui/classes/pixeldrain_downloader_thread.py
@@ -0,0 +1,101 @@
+import os
+import time
+import requests
+import cloudscraper
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.pixeldrain_client import fetch_pixeldrain_data
+from ...utils.file_utils import clean_folder_name
+
+
+class PixeldrainDownloadThread(QThread):
+ """A dedicated QThread for handling pixeldrain.com downloads."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.pixeldrain_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+
+ def run(self):
+ download_count = 0
+ skip_count = 0
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Pixeldrain.com Download for: {self.pixeldrain_url}")
+
+ album_title_raw, files_to_download = fetch_pixeldrain_data(self.pixeldrain_url, self.progress_signal.emit)
+
+ if not files_to_download:
+ self.progress_signal.emit("❌ Failed to extract file information from Pixeldrain. Aborting.")
+ self.finished_signal.emit(0, 0, self.is_cancelled)
+ return
+
+ album_folder_name = clean_folder_name(album_title_raw)
+ album_path = os.path.join(self.output_dir, album_folder_name)
+ try:
+ os.makedirs(album_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving to folder: '{album_path}'")
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ self.finished_signal.emit(0, len(files_to_download), self.is_cancelled)
+ return
+
+ total_files = len(files_to_download)
+ session = cloudscraper.create_scraper()
+
+ for i, file_data in enumerate(files_to_download):
+ if self.is_cancelled:
+ self.progress_signal.emit(" Download cancelled by user.")
+ skip_count = total_files - download_count
+ break
+
+ filename = file_data.get('filename')
+ file_url = file_data.get('url')
+ filepath = os.path.join(album_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
+ skip_count += 1
+ continue
+
+ self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
+
+ try:
+ response = session.get(file_url, stream=True, timeout=90)
+ response.raise_for_status()
+
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded_size = 0
+ last_update_time = time.time()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled:
+ break
+ if chunk:
+ f.write(chunk)
+ downloaded_size += len(chunk)
+ current_time = time.time()
+ if total_size > 0 and (current_time - last_update_time) > 0.5:
+ self.file_progress_signal.emit(filename, (downloaded_size, total_size))
+ last_update_time = current_time
+
+ if self.is_cancelled:
+ if os.path.exists(filepath): os.remove(filepath)
+ continue
+
+ download_count += 1
+ except requests.exceptions.RequestException as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ skip_count += 1
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Pixeldrain thread.")
\ No newline at end of file
diff --git a/src/ui/classes/rule34video_downloader_thread.py b/src/ui/classes/rule34video_downloader_thread.py
new file mode 100644
index 0000000..bd4acb1
--- /dev/null
+++ b/src/ui/classes/rule34video_downloader_thread.py
@@ -0,0 +1,87 @@
+import os
+import time
+import requests
+from PyQt5.QtCore import QThread, pyqtSignal
+import cloudscraper
+
+from ...core.rule34video_client import fetch_rule34video_data
+from ...utils.file_utils import clean_folder_name
+
+class Rule34VideoDownloadThread(QThread):
+ """A dedicated QThread for handling rule34video.com downloads."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.video_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+
+ def run(self):
+ download_count = 0
+ skip_count = 0
+
+ video_title, final_video_url = fetch_rule34video_data(self.video_url, self.progress_signal.emit)
+
+ if not final_video_url:
+ self.progress_signal.emit("❌ Failed to get video data. Aborting.")
+ self.finished_signal.emit(0, 1, self.is_cancelled)
+ return
+
+ # Create a safe filename from the title, defaulting if needed
+ safe_title = clean_folder_name(video_title if video_title else "rule34video_file")
+ filename = f"{safe_title}.mp4"
+ filepath = os.path.join(self.output_dir, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip: '{filename}' already exists.")
+ self.finished_signal.emit(0, 1, self.is_cancelled)
+ return
+
+ self.progress_signal.emit(f" Downloading: '{filename}'...")
+ try:
+ scraper = cloudscraper.create_scraper()
+ # The CDN link might not require special headers, but a referer is good practice
+ headers = {'Referer': 'https://rule34video.com/'}
+ response = scraper.get(final_video_url, stream=True, headers=headers, timeout=90)
+ response.raise_for_status()
+
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded_size = 0
+ last_update_time = time.time()
+
+ with open(filepath, 'wb') as f:
+ # Use a larger chunk size for video files
+ for chunk in response.iter_content(chunk_size=8192 * 4):
+ if self.is_cancelled:
+ break
+ if chunk:
+ f.write(chunk)
+ downloaded_size += len(chunk)
+ current_time = time.time()
+ if total_size > 0 and (current_time - last_update_time) > 0.5:
+ self.file_progress_signal.emit(filename, (downloaded_size, total_size))
+ last_update_time = current_time
+
+ if self.is_cancelled:
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ skip_count = 1
+ self.progress_signal.emit(f" Download cancelled for '{filename}'.")
+ else:
+ download_count = 1
+
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename}': {e}")
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ skip_count = 1
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Rule34Video thread.")
\ No newline at end of file
diff --git a/src/ui/classes/saint2_downloader_thread.py b/src/ui/classes/saint2_downloader_thread.py
new file mode 100644
index 0000000..5cd243b
--- /dev/null
+++ b/src/ui/classes/saint2_downloader_thread.py
@@ -0,0 +1,105 @@
+import os
+import time
+import requests
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.saint2_client import fetch_saint2_data
+
+class Saint2DownloadThread(QThread):
+ """A dedicated QThread for handling saint2.su downloads."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.saint2_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+
+ def run(self):
+ download_count = 0
+ skip_count = 0
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Saint2.su Download for: {self.saint2_url}")
+
+ album_name, files_to_download = fetch_saint2_data(self.saint2_url, self.progress_signal.emit)
+
+ if not files_to_download:
+ self.progress_signal.emit("❌ Failed to extract file information from Saint2. Aborting.")
+ self.finished_signal.emit(0, 0, self.is_cancelled)
+ return
+
+ album_path = os.path.join(self.output_dir, album_name)
+ try:
+ os.makedirs(album_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving to folder: '{album_path}'")
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ self.finished_signal.emit(0, len(files_to_download), self.is_cancelled)
+ return
+
+ total_files = len(files_to_download)
+ session = requests.Session()
+
+ for i, file_data in enumerate(files_to_download):
+ if self.is_cancelled:
+ self.progress_signal.emit(" Download cancelled by user.")
+ skip_count = total_files - download_count
+ break
+
+ filename = file_data.get('filename', f'untitled_{i+1}.mp4')
+ file_url = file_data.get('url')
+ headers = file_data.get('headers')
+ filepath = os.path.join(album_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
+ skip_count += 1
+ continue
+
+ self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
+
+ try:
+ response = session.get(file_url, stream=True, headers=headers, timeout=60)
+ response.raise_for_status()
+
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded_size = 0
+ last_update_time = time.time()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled:
+ break
+ if chunk:
+ f.write(chunk)
+ downloaded_size += len(chunk)
+ current_time = time.time()
+ if total_size > 0 and (current_time - last_update_time) > 0.5:
+ self.file_progress_signal.emit(filename, (downloaded_size, total_size))
+ last_update_time = current_time
+
+ if self.is_cancelled:
+ if os.path.exists(filepath): os.remove(filepath)
+ continue
+
+ if total_size > 0:
+ self.file_progress_signal.emit(filename, (total_size, total_size))
+
+ download_count += 1
+ except requests.exceptions.RequestException as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ skip_count += 1
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ An unexpected error occurred with '{filename}': {e}")
+ if os.path.exists(filepath): os.remove(filepath)
+ skip_count += 1
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Saint2 thread.")
\ No newline at end of file
diff --git a/src/ui/classes/simp_city_downloader_thread.py b/src/ui/classes/simp_city_downloader_thread.py
new file mode 100644
index 0000000..e630acb
--- /dev/null
+++ b/src/ui/classes/simp_city_downloader_thread.py
@@ -0,0 +1,347 @@
+import os
+import queue
+import re
+import threading
+import time
+from collections import Counter
+from concurrent.futures import ThreadPoolExecutor
+from urllib.parse import urlparse
+
+import cloudscraper
+import requests
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.bunkr_client import fetch_bunkr_data
+from ...core.pixeldrain_client import fetch_pixeldrain_data
+from ...core.saint2_client import fetch_saint2_data
+from ...core.simpcity_client import fetch_single_simpcity_page
+from ...services.drive_downloader import (
+ download_mega_file as drive_download_mega_file,
+ download_gofile_folder
+)
+from ...utils.file_utils import clean_folder_name
+
+
+class SimpCityDownloadThread(QThread):
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool, list)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, url, post_id, output_dir, cookies, parent=None):
+ super().__init__(parent)
+ self.start_url = url
+ self.post_id = post_id
+ self.output_dir = output_dir
+ self.cookies = cookies
+ self.is_cancelled = False
+ self.parent_app = parent
+ self.image_queue = queue.Queue()
+ self.service_queue = queue.Queue()
+ self.counter_lock = threading.Lock()
+ self.total_dl_count = 0
+ self.total_skip_count = 0
+ self.total_jobs_found = 0
+ self.total_jobs_processed = 0
+ self.processed_job_urls = set()
+
+ def cancel(self):
+ self.is_cancelled = True
+
+ class _ServiceLoggerAdapter:
+ """Wraps the progress signal to provide .info(), .error(), .warning() methods for other clients."""
+ def __init__(self, signal_emitter, prefix=""):
+ self.emit = signal_emitter
+ self.prefix = prefix
+
+ def __call__(self, msg, *args, **kwargs):
+ # Make the logger callable, defaulting to the info method.
+ self.info(msg, *args, **kwargs)
+
+ def info(self, msg, *args, **kwargs): self.emit(f"{self.prefix}{str(msg) % args}")
+ def error(self, msg, *args, **kwargs): self.emit(f"{self.prefix}❌ ERROR: {str(msg) % args}")
+ def warning(self, msg, *args, **kwargs): self.emit(f"{self.prefix}⚠️ WARNING: {str(msg) % args}")
+
+ def _log_interceptor(self, message):
+ """Filters out verbose log messages from the simpcity_client."""
+ if "[SimpCity] Scraper found" in message or "[SimpCity] Scraping page" in message:
+ pass
+ else:
+ self.progress_signal.emit(message)
+
+ def _get_enriched_jobs(self, jobs_to_check):
+ """Performs a pre-flight check on jobs to get an accurate total file count and summary."""
+ if not jobs_to_check:
+ return []
+
+ enriched_jobs = []
+
+ bunkr_logger = self._ServiceLoggerAdapter(self.progress_signal.emit, prefix=" ")
+ pixeldrain_logger = self._ServiceLoggerAdapter(self.progress_signal.emit, prefix=" ")
+ saint2_logger = self._ServiceLoggerAdapter(self.progress_signal.emit, prefix=" ")
+
+ for job in jobs_to_check:
+ job_type = job.get('type')
+ job_url = job.get('url')
+
+ if job_type in ['image', 'saint2_direct']:
+ enriched_jobs.append(job)
+ elif (job_type == 'bunkr' and self.should_dl_bunkr) or \
+ (job_type == 'pixeldrain' and self.should_dl_pixeldrain) or \
+ (job_type == 'saint2' and self.should_dl_saint2):
+ self.progress_signal.emit(f" -> Checking {job_type} album for file count...")
+
+ fetch_map = {
+ 'bunkr': (fetch_bunkr_data, bunkr_logger),
+ 'pixeldrain': (fetch_pixeldrain_data, pixeldrain_logger),
+ 'saint2': (fetch_saint2_data, saint2_logger)
+ }
+ fetch_func, logger_adapter = fetch_map[job_type]
+ album_name, files = fetch_func(job_url, logger_adapter)
+
+ if files:
+ job['prefetched_files'] = files
+ job['prefetched_album_name'] = album_name
+ enriched_jobs.append(job)
+
+ if enriched_jobs:
+ summary_counts = Counter()
+ current_page_file_count = 0
+ for job in enriched_jobs:
+ if job.get('prefetched_files'):
+ file_count = len(job['prefetched_files'])
+ summary_counts[job['type']] += file_count
+ current_page_file_count += file_count
+ else:
+ summary_counts[job['type']] += 1
+ current_page_file_count += 1
+
+ summary_parts = [f"{job_type} ({count})" for job_type, count in summary_counts.items()]
+ self.progress_signal.emit(f" [SimpCity] Content Found: {' | '.join(summary_parts)}")
+
+ with self.counter_lock: self.total_jobs_found += current_page_file_count
+ self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
+
+ return enriched_jobs
+
+ def _download_single_image(self, job, album_path, session):
+ """Downloads one image file; this is run by the image thread pool."""
+ filename = job['filename']
+ filepath = os.path.join(album_path, filename)
+ try:
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip (Image): '{filename}'")
+ with self.counter_lock: self.total_skip_count += 1
+ return
+ self.progress_signal.emit(f" -> Downloading (Image): '{filename}'...")
+ response = session.get(job['url'], stream=True, timeout=90, headers={'Referer': self.start_url})
+ response.raise_for_status()
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled: break
+ f.write(chunk)
+ if not self.is_cancelled:
+ with self.counter_lock: self.total_dl_count += 1
+ except Exception as e:
+ self.progress_signal.emit(f" -> ❌ Image download failed for '{filename}': {e}")
+ with self.counter_lock: self.total_skip_count += 1
+ finally:
+ if not self.is_cancelled:
+ with self.counter_lock: self.total_jobs_processed += 1
+ self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
+
+ def _image_worker(self, album_path):
+ """Target function for the image thread pool that pulls jobs from the queue."""
+ session = cloudscraper.create_scraper()
+ while True:
+ if self.is_cancelled: break
+ try:
+ job = self.image_queue.get(timeout=1)
+ if job is None: break
+ self._download_single_image(job, album_path, session)
+ self.image_queue.task_done()
+ except queue.Empty:
+ continue
+
+ def _service_worker(self, album_path):
+ """Target function for the single service thread, ensuring sequential downloads."""
+ while True:
+ if self.is_cancelled: break
+ try:
+ job = self.service_queue.get(timeout=1)
+ if job is None: break
+
+ job_type = job['type']
+ job_url = job['url']
+
+ if job_type in ['pixeldrain', 'saint2', 'bunkr']:
+ if (job_type == 'pixeldrain' and self.should_dl_pixeldrain) or \
+ (job_type == 'saint2' and self.should_dl_saint2) or \
+ (job_type == 'bunkr' and self.should_dl_bunkr):
+ self.progress_signal.emit(f"\n--- Processing Service ({job_type.capitalize()}): {job_url} ---")
+ self._download_album(job.get('prefetched_files', []), job_url, album_path)
+ elif job_type == 'mega' and self.should_dl_mega:
+ self.progress_signal.emit(f"\n--- Processing Service (Mega): {job_url} ---")
+ drive_download_mega_file(job_url, album_path, self.progress_signal.emit, self.file_progress_signal.emit)
+ elif job_type == 'gofile' and self.should_dl_gofile:
+ self.progress_signal.emit(f"\n--- Processing Service (Gofile): {job_url} ---")
+ download_gofile_folder(job_url, album_path, self.progress_signal.emit, self.file_progress_signal.emit)
+ elif job_type == 'saint2_direct' and self.should_dl_saint2:
+ self.progress_signal.emit(f"\n--- Processing Service (Saint2 Direct): {job_url} ---")
+ try:
+ filename = os.path.basename(urlparse(job_url).path)
+ filepath = os.path.join(album_path, filename)
+ if os.path.exists(filepath):
+ with self.counter_lock: self.total_skip_count += 1
+ else:
+ response = cloudscraper.create_scraper().get(job_url, stream=True, timeout=120, headers={'Referer': self.start_url})
+ response.raise_for_status()
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled: break
+ f.write(chunk)
+ if not self.is_cancelled:
+ with self.counter_lock: self.total_dl_count += 1
+ except Exception as e:
+ with self.counter_lock: self.total_skip_count += 1
+ finally:
+ if not self.is_cancelled:
+ with self.counter_lock: self.total_jobs_processed += 1
+ self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
+
+ self.service_queue.task_done()
+ except queue.Empty:
+ continue
+
+ def _download_album(self, files_to_process, source_url, album_path):
+ """Helper to download all files from a pre-fetched album list."""
+ if not files_to_process: return
+ session = cloudscraper.create_scraper()
+ for file_data in files_to_process:
+ if self.is_cancelled: return
+ filename = file_data.get('filename') or file_data.get('name')
+ filepath = os.path.join(album_path, filename)
+ try:
+ if os.path.exists(filepath):
+ with self.counter_lock: self.total_skip_count += 1
+ else:
+ self.progress_signal.emit(f" -> Downloading: '{filename}'...")
+ headers = file_data.get('headers', {'Referer': source_url})
+ response = session.get(file_data.get('url'), stream=True, timeout=90, headers=headers)
+ response.raise_for_status()
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self.is_cancelled: break
+ f.write(chunk)
+ if not self.is_cancelled:
+ with self.counter_lock: self.total_dl_count += 1
+ except Exception as e:
+ with self.counter_lock: self.total_skip_count += 1
+ finally:
+ if not self.is_cancelled:
+ with self.counter_lock: self.total_jobs_processed += 1
+ self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
+
+ def run(self):
+ """Main entry point for the thread, orchestrates the entire download."""
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting SimpCity Download for: {self.start_url}")
+
+ self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
+ self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
+ self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
+ self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
+ self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
+
+ is_single_post_mode = self.post_id or '/post-' in self.start_url
+ album_path = ""
+
+ try:
+ if is_single_post_mode:
+ self.progress_signal.emit(" Mode: Single Post detected.")
+ album_title, _, _ = fetch_single_simpcity_page(self.start_url, self._log_interceptor, cookies=self.cookies, post_id=self.post_id)
+ album_path = os.path.join(self.output_dir, clean_folder_name(album_title or "simpcity_post"))
+ else:
+ self.progress_signal.emit(" Mode: Full Thread detected.")
+ first_page_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
+ album_title, _, _ = fetch_single_simpcity_page(first_page_url, self._log_interceptor, cookies=self.cookies)
+ album_path = os.path.join(self.output_dir, clean_folder_name(album_title or "simpcity_album"))
+ os.makedirs(album_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving all content to folder: '{os.path.basename(album_path)}'")
+ except Exception as e:
+ self.progress_signal.emit(f"❌ Could not process the initial page. Aborting. Error: {e}")
+ self.finished_signal.emit(0, 0, self.is_cancelled, []); return
+
+ service_thread = threading.Thread(target=self._service_worker, args=(album_path,), daemon=True)
+ service_thread.start()
+ num_image_threads = 15
+ image_executor = ThreadPoolExecutor(max_workers=num_image_threads, thread_name_prefix='SimpCityImage')
+ for _ in range(num_image_threads): image_executor.submit(self._image_worker, album_path)
+
+ try:
+ if is_single_post_mode:
+ _, jobs, _ = fetch_single_simpcity_page(self.start_url, self._log_interceptor, cookies=self.cookies, post_id=self.post_id)
+ enriched_jobs = self._get_enriched_jobs(jobs)
+ if enriched_jobs:
+ for job in enriched_jobs:
+ if job['type'] == 'image': self.image_queue.put(job)
+ else: self.service_queue.put(job)
+ else:
+ base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
+ page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
+ while not end_of_thread:
+ if self.is_cancelled: break
+ page_url = f"{base_url}/page-{page_counter}"; retries = 0; page_fetch_successful = False
+ while retries < MAX_RETRIES:
+ if self.is_cancelled: end_of_thread = True; break
+ self.progress_signal.emit(f"\n--- Analyzing page {page_counter} (Attempt {retries + 1}/{MAX_RETRIES}) ---")
+ try:
+ page_title, jobs_on_page, final_url = fetch_single_simpcity_page(page_url, self._log_interceptor, cookies=self.cookies)
+
+ if final_url != page_url:
+ self.progress_signal.emit(f" -> Redirect detected from {page_url} to {final_url}")
+ try:
+ req_page_match = re.search(r'/page-(\d+)', page_url)
+ final_page_match = re.search(r'/page-(\d+)', final_url)
+ if req_page_match and final_page_match and int(final_page_match.group(1)) < int(req_page_match.group(1)):
+ self.progress_signal.emit(" -> Redirected to an earlier page. Reached end of thread.")
+ end_of_thread = True
+ except (ValueError, TypeError):
+ pass
+
+ if end_of_thread:
+ page_fetch_successful = True; break
+
+ if page_counter > 1 and not page_title:
+ self.progress_signal.emit(f" -> Page {page_counter} is invalid or has no title. Reached end of thread.")
+ end_of_thread = True
+ elif not jobs_on_page:
+ end_of_thread = True
+ else:
+ new_jobs = [job for job in jobs_on_page if job.get('url') not in self.processed_job_urls]
+ if not new_jobs and page_counter > 1:
+ end_of_thread = True
+ else:
+ enriched_jobs = self._get_enriched_jobs(new_jobs)
+ for job in enriched_jobs:
+ self.processed_job_urls.add(job.get('url'))
+ if job['type'] == 'image': self.image_queue.put(job)
+ else: self.service_queue.put(job)
+ page_fetch_successful = True; break
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code in [403, 404]: end_of_thread = True; break
+ elif e.response.status_code == 429: time.sleep(5 * (retries + 2)); retries += 1
+ else: end_of_thread = True; break
+ except Exception as e:
+ self.progress_signal.emit(f" Stopping crawl due to error on page {page_counter}: {e}"); end_of_thread = True; break
+ if not page_fetch_successful and not end_of_thread: end_of_thread = True
+ if not end_of_thread: page_counter += 1
+ except Exception as e:
+ self.progress_signal.emit(f"❌ A critical error occurred during the main fetch phase: {e}")
+
+ self.progress_signal.emit("\n--- All pages analyzed. Waiting for background downloads to complete... ---")
+ for _ in range(num_image_threads): self.image_queue.put(None)
+ self.service_queue.put(None)
+ image_executor.shutdown(wait=True)
+ service_thread.join()
+ self.finished_signal.emit(self.total_dl_count, self.total_skip_count, self.is_cancelled, [])
\ No newline at end of file
diff --git a/src/ui/classes/toonily_downloader_thread.py b/src/ui/classes/toonily_downloader_thread.py
new file mode 100644
index 0000000..fec790f
--- /dev/null
+++ b/src/ui/classes/toonily_downloader_thread.py
@@ -0,0 +1,128 @@
+import os
+import threading
+import time
+from urllib.parse import urlparse
+
+import cloudscraper
+from PyQt5.QtCore import QThread, pyqtSignal
+
+from ...core.toonily_client import (
+ fetch_chapter_data as toonily_fetch_data,
+ get_chapter_list as toonily_get_list
+)
+from ...utils.file_utils import clean_folder_name
+
+
+class ToonilyDownloadThread(QThread):
+ """A dedicated QThread for handling toonily.com series or single chapters."""
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool)
+ overall_progress_signal = pyqtSignal(int, int) # Signal for chapter progress
+
+ def __init__(self, url, output_dir, parent=None):
+ super().__init__(parent)
+ self.start_url = url
+ self.output_dir = output_dir
+ self.is_cancelled = False
+ # Get access to the pause event from the main app
+ self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
+
+ def _check_pause(self):
+ # Helper function to check for pause/cancel events
+ if self.is_cancelled: return True
+ if self.pause_event and self.pause_event.is_set():
+ self.progress_signal.emit(" Download paused...")
+ while self.pause_event.is_set():
+ if self.is_cancelled: return True
+ time.sleep(0.5)
+ self.progress_signal.emit(" Download resumed.")
+ return self.is_cancelled
+
+ def run(self):
+ grand_total_dl = 0
+ grand_total_skip = 0
+
+ # Check if the URL is a series or a chapter
+ if '/chapter-' in self.start_url:
+ # It's a single chapter URL
+ chapters_to_download = [self.start_url]
+ self.progress_signal.emit("ℹ️ Single Toonily chapter URL detected.")
+ else:
+ # It's a series URL, so get all chapters
+ chapters_to_download = toonily_get_list(self.start_url, self.progress_signal.emit)
+
+ if not chapters_to_download:
+ self.progress_signal.emit("❌ No chapters found to download.")
+ self.finished_signal.emit(0, 0, self.is_cancelled)
+ return
+
+ self.progress_signal.emit(f"--- Starting download of {len(chapters_to_download)} chapter(s) ---")
+ self.overall_progress_signal.emit(len(chapters_to_download), 0)
+
+ scraper = cloudscraper.create_scraper()
+
+ for chapter_idx, chapter_url in enumerate(chapters_to_download):
+ if self._check_pause(): break
+
+ self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --")
+ series_title, chapter_title, image_urls = toonily_fetch_data(chapter_url, self.progress_signal.emit, scraper)
+
+ if not image_urls:
+ self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
+ continue
+
+ # Create folders like: /Downloads/Series Name/Chapter 01/
+ series_folder_name = clean_folder_name(series_title)
+ # Make a safe folder name from the full chapter title
+ chapter_folder_name = clean_folder_name(chapter_title)
+ final_save_path = os.path.join(self.output_dir, series_folder_name, chapter_folder_name)
+
+ try:
+ os.makedirs(final_save_path, exist_ok=True)
+ self.progress_signal.emit(f" Saving to folder: '{os.path.join(series_folder_name, chapter_folder_name)}'")
+ except OSError as e:
+ self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
+ grand_total_skip += len(image_urls)
+ continue
+
+ for i, img_url in enumerate(image_urls):
+ if self._check_pause(): break
+
+ try:
+ file_extension = os.path.splitext(urlparse(img_url).path)[1] or '.jpg'
+ filename = f"{i+1:03d}{file_extension}"
+ filepath = os.path.join(final_save_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({i+1}/{len(image_urls)}): '{filename}' already exists.")
+ grand_total_skip += 1
+ else:
+ self.progress_signal.emit(f" Downloading ({i+1}/{len(image_urls)}): '{filename}'...")
+ response = scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
+ response.raise_for_status()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self._check_pause(): break
+ f.write(chunk)
+
+ if self._check_pause():
+ if os.path.exists(filepath): os.remove(filepath)
+ break
+
+ grand_total_dl += 1
+ time.sleep(0.2)
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename}': {e}")
+ grand_total_skip += 1
+
+ self.overall_progress_signal.emit(len(chapters_to_download), chapter_idx + 1)
+ time.sleep(1) # Wait a second between chapters
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(grand_total_dl, grand_total_skip, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Toonily thread.")
\ No newline at end of file
diff --git a/src/ui/dialogs/SupportDialog.py b/src/ui/dialogs/SupportDialog.py
index 2cff270..ebed774 100644
--- a/src/ui/dialogs/SupportDialog.py
+++ b/src/ui/dialogs/SupportDialog.py
@@ -153,7 +153,7 @@ class SupportDialog(QDialog):
community_layout.addWidget(self._create_card_button(
get_asset_path("github.png"), "GitHub", "Report issues",
- "https://github.com/Yuvi63771/Kemono-Downloader", "#2E2E2E",
+ "https://github.com/Yuvi9587/Kemono-Downloader", "#2E2E2E",
min_height=100, icon_size=36
))
community_layout.addWidget(self._create_card_button(
diff --git a/src/ui/main_window.py b/src/ui/main_window.py
index 9c56b6e..ba53761 100644
--- a/src/ui/main_window.py
+++ b/src/ui/main_window.py
@@ -88,6 +88,22 @@ from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
from .dialogs.MultipartScopeDialog import MultipartScopeDialog
from .dialogs.ExportLinksDialog import ExportLinksDialog
from .dialogs.CustomFilenameDialog import CustomFilenameDialog
+from .classes.erome_downloader_thread import EromeDownloadThread
+from .classes.saint2_downloader_thread import Saint2DownloadThread
+from .classes.discord_downloader_thread import DiscordDownloadThread
+from .classes.fap_nation_downloader_thread import FapNationDownloadThread
+from .classes.simp_city_downloader_thread import SimpCityDownloadThread
+from .classes.allcomic_downloader_thread import AllcomicDownloadThread
+from .classes.toonily_downloader_thread import ToonilyDownloadThread
+from .classes.bunkr_downloader_thread import BunkrDownloadThread
+from .classes.hentai2read_downloader_thread import Hentai2readDownloadThread
+from .classes.booru_downloader_thread import BooruDownloadThread
+from .classes.pixeldrain_downloader_thread import PixeldrainDownloadThread
+from .classes.mangadex_downloader_thread import MangaDexDownloadThread
+from .classes.drive_downloader_thread import DriveDownloadThread
+from .classes.external_link_downloader_thread import ExternalLinkDownloadThread
+from .classes.nhentai_downloader_thread import NhentaiDownloadThread
+from .classes.downloader_factory import create_downloader_thread
_ff_ver = (datetime.date.today().toordinal() - 735506) // 28
USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; "
@@ -317,7 +333,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
- self.setWindowTitle("Kemono Downloader v7.5.0")
+ self.setWindowTitle("Kemono Downloader v7.5.1")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@@ -476,6 +492,22 @@ class DownloaderApp (QWidget ):
except Exception as e:
self.log_signal.emit(f"⚠️ Could not remove old application file: {e}")
+ def _connect_specialized_thread_signals(self, thread):
+ """Connects common signals for specialized downloader threads."""
+ if hasattr(thread, 'progress_signal'):
+ thread.progress_signal.connect(self.handle_main_log)
+ if hasattr(thread, 'file_progress_signal'):
+ thread.file_progress_signal.connect(self.update_file_progress_display)
+ if hasattr(thread, 'overall_progress_signal'):
+ thread.overall_progress_signal.connect(self.update_progress_display)
+ if hasattr(thread, 'finished_signal'):
+ # This lambda handles threads that return 2, 3, or 4 arguments for the finished signal
+ thread.finished_signal.connect(
+ lambda dl, skip, cancelled, names=None: self.download_finished(dl, skip, cancelled, names or [])
+ )
+ if hasattr(thread, 'progress_label_signal'): # For Discord thread
+ thread.progress_label_signal.connect(self.progress_label.setText)
+
def _apply_theme_and_restart_prompt(self):
"""Applies the theme and prompts the user to restart."""
if self.current_theme == "dark":
@@ -2997,7 +3029,7 @@ class DownloaderApp (QWidget ):
def update_ui_for_subfolders (self ,separate_folders_by_name_title_checked :bool ):
current_filter_mode = self.get_filter_mode()
- can_enable_sfp = current_filter_mode not in ['archive', 'audio', 'text_only']
+ can_enable_sfp = current_filter_mode not in ['audio', 'text_only']
if self .use_subfolder_per_post_checkbox :
self.use_subfolder_per_post_checkbox.setEnabled(can_enable_sfp)
@@ -3486,6 +3518,94 @@ class DownloaderApp (QWidget ):
return get_theme_stylesheet(actual_scale)
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
+ if not direct_api_url:
+ api_url_text = self.link_input.text().strip().lower()
+ batch_handlers = {
+ 'allporncomic.com': {
+ 'name': 'AllPornComic',
+ 'txt_file': 'allporncomic.txt',
+ 'url_regex': r'https?://allporncomic\.com/porncomic/[^/\s]+(?:/[^/\s]+)?/?'
+ },
+ 'nhentai.net': {
+ 'name': 'nhentai',
+ 'txt_file': 'nhentai.txt',
+ 'url_regex': r'https?://nhentai\.net/g/\d+/?'
+ },
+ 'fap-nation.com': {
+ 'name': 'Fap-Nation',
+ 'txt_file': 'fap-nation.txt',
+ 'url_regex': r'https?://(?:www\.)?fap-nation\.(?:com|org)/[^/\s]+/?'
+ },
+ 'fap-nation.org': {
+ 'name': 'Fap-Nation',
+ 'txt_file': 'fap-nation.txt',
+ 'url_regex': r'https?://(?:www\.)?fap-nation\.(?:com|org)/[^/\s]+/?'
+ },
+ 'saint2.su': {
+ 'name': 'Saint2.su',
+ 'txt_file': 'saint2.su.txt',
+ 'url_regex': r'https?://saint\d*\.(?:su|pk|cr|to)/(?:a|d|embed)/[^/?#\s]+'
+ },
+ 'hentai2read.com': {
+ 'name': 'Hentai2Read',
+ 'txt_file': 'hentai2read.txt',
+ 'url_regex': r'https?://hentai2read\.com/[^/\s]+(?:/\d+)?/?'
+ },
+ 'rule34video.com': {
+ 'name': 'Rule34Video.com',
+ 'txt_file': 'rule34video.txt',
+ 'url_regex': r'https?://rule34video\.com/video/\d+/[^/\s]+/?'
+ }
+ }
+
+ if api_url_text in batch_handlers:
+ handler = batch_handlers[api_url_text]
+ name = handler['name']
+ txt_file = handler['txt_file']
+ url_regex = handler['url_regex']
+
+ self.log_signal.emit("=" * 40)
+ self.log_signal.emit(f"🚀 {name} batch download mode detected.")
+
+ txt_path = os.path.join(self.user_data_path, txt_file)
+ self.log_signal.emit(f" Looking for batch file at: {txt_path}")
+
+ if not os.path.exists(txt_path):
+ QMessageBox.warning(self, "File Not Found", f"To use batch mode, create a file named '{txt_file}' in your 'appdata' folder.\n\nPlace one {name} URL on each line.")
+ self.log_signal.emit(f" ❌ '{txt_file}' not found. Aborting batch download.")
+ return False
+
+ urls_to_download = []
+ try:
+ with open(txt_path, 'r', encoding='utf-8') as f:
+ for line in f:
+ found_urls = re.findall(url_regex, line)
+ if found_urls:
+ urls_to_download.extend(found_urls)
+ except Exception as e:
+ QMessageBox.critical(self, "File Error", f"Could not read '{txt_file}':\n{e}")
+ self.log_signal.emit(f" ❌ Error reading '{txt_file}': {e}")
+ return False
+
+ if not urls_to_download:
+ QMessageBox.information(self, "Empty File", f"No valid {name} URLs were found in '{txt_file}'.")
+ self.log_signal.emit(f" '{txt_file}' was found but contained no valid URLs.")
+ return False
+
+ self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
+ self.favorite_download_queue.clear()
+ for url in urls_to_download:
+ self.favorite_download_queue.append({
+ 'url': url,
+ 'name': f"{name} link from batch",
+ 'type': 'post'
+ })
+
+ if not self.is_processing_favorites_queue:
+ self._process_next_favorite_download()
+ return True # Stop further execution of start_download
+
+
from ..utils.file_utils import clean_folder_name
from ..config.constants import FOLDER_NAME_STOP_WORDS
@@ -3552,111 +3672,7 @@ class DownloaderApp (QWidget ):
self.is_restore_pending = False
api_url = direct_api_url if direct_api_url else self.link_input.text().strip()
- service, _, _ = extract_post_info(api_url)
-
-
- if service in ['danbooru', 'gelbooru']:
- self.log_signal.emit(f"ℹ️ {service.capitalize()} URL detected. Starting dedicated download.")
-
- output_dir = self.dir_input.text().strip()
- if not output_dir or not os.path.isdir(output_dir):
- QMessageBox.critical(self, "Input Error", "A valid Download Location is required.")
- return False
-
- api_key = self.api_key_input.text().strip()
- user_id = self.user_id_input.text().strip()
-
- self.set_ui_enabled(False)
- self.download_thread = BooruDownloadThread(
- url=api_url,
- output_dir=output_dir,
- api_key=api_key,
- user_id=user_id,
- parent=self
- )
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.overall_progress_signal.connect(self.update_progress_display)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True # Important to exit here
-
- platform = None
- if 'mega.nz' in api_url or 'mega.io' in api_url:
- platform = 'mega'
- elif 'drive.google.com' in api_url:
- platform = 'gdrive'
- elif 'dropbox.com' in api_url:
- platform = 'dropbox'
- elif 'gofile.io' in api_url:
- platform = 'gofile'
-
- if platform:
- main_ui_download_dir = self.dir_input.text().strip()
- if not main_ui_download_dir or not os.path.isdir(main_ui_download_dir):
- QMessageBox.critical(self, "Input Error", "A valid Download Directory is required.")
- return False
-
- self.set_ui_enabled(False)
- use_post_subfolder = self.use_subfolder_per_post_checkbox.isChecked()
- self.download_thread = DriveDownloadThread(
- api_url, main_ui_download_dir, platform, use_post_subfolder,
- self.cancellation_event, self.pause_event, self.log_signal.emit
- )
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.overall_progress_signal.connect(lambda total, processed: self.update_progress_display(total, processed, unit="files"))
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled, kept_names: self.download_finished(dl, skip, cancelled, kept_names)
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if api_url.strip().lower() in ['allporncomic.com', 'https://allporncomic.com', 'http://allporncomic.com']:
- self.log_signal.emit("=" * 40)
- self.log_signal.emit("🚀 AllPornComic batch download mode detected.")
-
- allporncomic_txt_path = os.path.join(self.user_data_path, "allporncomic.txt")
- self.log_signal.emit(f" Looking for batch file at: {allporncomic_txt_path}")
-
- if not os.path.exists(allporncomic_txt_path):
- QMessageBox.warning(self, "File Not Found", f"To use batch mode, create a file named 'allporncomic.txt' in your 'appdata' folder.\n\nPlace one AllPornComic URL (series or chapter) on each line.")
- self.log_signal.emit(f" ❌ 'allporncomic.txt' not found. Aborting batch download.")
- return False
-
- urls_to_download = []
- try:
- with open(allporncomic_txt_path, 'r', encoding='utf-8') as f:
- for line in f:
- # This regex finds both series URLs and direct chapter URLs
- found_urls = re.findall(r'https?://allporncomic\.com/porncomic/[^/\s]+(?:/[^/\s]+)?/?', line)
- if found_urls:
- urls_to_download.extend(found_urls)
- except Exception as e:
- QMessageBox.critical(self, "File Error", f"Could not read 'allporncomic.txt':\n{e}")
- self.log_signal.emit(f" ❌ Error reading 'allporncomic.txt': {e}")
- return False
-
- if not urls_to_download:
- QMessageBox.information(self, "Empty File", "No valid AllPornComic URLs were found in 'allporncomic.txt'.")
- self.log_signal.emit(" 'allporncomic.txt' was found but contained no valid URLs.")
- return False
-
- self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
- self.favorite_download_queue.clear()
- for url in urls_to_download:
- self.favorite_download_queue.append({
- 'url': url,
- 'name': f"AllPornComic link from batch",
- 'type': 'post'
- })
-
- if not self.is_processing_favorites_queue:
- self._process_next_favorite_download()
- return True
-
+
main_ui_download_dir = self.dir_input.text().strip()
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
effective_output_dir_for_run = ""
@@ -3679,8 +3695,6 @@ class DownloaderApp (QWidget ):
return False
effective_output_dir_for_run = os.path.normpath(override_output_dir)
else:
- is_special_downloader = 'saint2.su' in api_url or 'saint2.pk' in api_url or 'nhentai.net' in api_url or 'bunkr' in api_url or 'erome.com' in api_url
-
if not extract_links_only and not main_ui_download_dir:
QMessageBox.critical(self, "Input Error", "Download Directory is required.")
return False
@@ -3701,224 +3715,6 @@ class DownloaderApp (QWidget ):
return False
effective_output_dir_for_run = os.path.normpath(main_ui_download_dir) if main_ui_download_dir else ""
- if 'erome.com' in api_url:
- self.log_signal.emit("ℹ️ Erome.com URL detected. Starting dedicated Erome download.")
- self.set_ui_enabled(False)
-
- self.download_thread = EromeDownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if 'mangadex.org' in api_url:
- self.log_signal.emit("ℹ️ MangaDex URL detected. Starting dedicated downloader.")
- self.set_ui_enabled(False)
-
- self.download_thread = MangaDexDownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.overall_progress_signal.connect(lambda total, processed: self.update_progress_display(total, processed, unit="chapters"))
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
-
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
-
- if 'nhentai.net' in api_url and not re.search(r'/g/(\d+)', api_url):
- self.log_signal.emit("=" * 40)
- self.log_signal.emit("🚀 nhentai batch download mode detected.")
-
- nhentai_txt_path = os.path.join(self.user_data_path, "nhentai.txt")
- self.log_signal.emit(f" Looking for batch file at: {nhentai_txt_path}")
-
- if not os.path.exists(nhentai_txt_path):
- QMessageBox.warning(self, "File Not Found", f"To use batch mode, create a file named 'nhentai.txt' in your 'appdata' folder.\n\nPlace one nhentai URL on each line.")
- self.log_signal.emit(f" ❌ 'nhentai.txt' not found. Aborting batch download.")
- return False
-
- urls_to_download = []
- try:
- with open(nhentai_txt_path, 'r', encoding='utf-8') as f:
- for line in f:
- found_urls = re.findall(r'https?://nhentai\.net/g/\d+/?', line)
- if found_urls:
- urls_to_download.extend(found_urls)
- except Exception as e:
- QMessageBox.critical(self, "File Error", f"Could not read 'nhentai.txt':\n{e}")
- self.log_signal.emit(f" ❌ Error reading 'nhentai.txt': {e}")
- return False
-
- if not urls_to_download:
- QMessageBox.information(self, "Empty File", "No valid nhentai gallery URLs were found in 'nhentai.txt'.")
- self.log_signal.emit(" 'nhentai.txt' was found but contained no valid URLs.")
- return False
-
- self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
- self.favorite_download_queue.clear()
- for url in urls_to_download:
- self.favorite_download_queue.append({
- 'url': url,
- 'name': f"nhentai gallery from batch",
- 'type': 'post'
- })
-
- if not self.is_processing_favorites_queue:
- self._process_next_favorite_download()
- return True
-
-
- fap_nation_batch_triggers = ['fap-nation.com', 'fap-nation.org']
- if any(trigger in api_url.lower() and '/' not in api_url.split('//')[-1] for trigger in fap_nation_batch_triggers):
- self.log_signal.emit("=" * 40)
- self.log_signal.emit("🚀 Fap-Nation batch download mode detected.")
-
- fap_nation_txt_path = os.path.join(self.user_data_path, "fap-nation.txt")
- self.log_signal.emit(f" Looking for batch file at: {fap_nation_txt_path}")
-
- if not os.path.exists(fap_nation_txt_path):
- QMessageBox.warning(self, "File Not Found", f"To use batch mode, create a file named 'fap-nation.txt' in your 'appdata' folder.\n\nPlace one Fap-Nation album URL on each line.")
- self.log_signal.emit(f" ❌ 'fap-nation.txt' not found. Aborting batch download.")
- return False
-
- urls_to_download = []
- try:
- with open(fap_nation_txt_path, 'r', encoding='utf-8') as f:
- for line in f:
- found_urls = re.findall(r'https?://(?:www\.)?fap-nation\.(?:com|org)/[^/\s]+/?', line)
- if found_urls:
- urls_to_download.extend(found_urls)
- except Exception as e:
- QMessageBox.critical(self, "File Error", f"Could not read 'fap-nation.txt':\n{e}")
- self.log_signal.emit(f" ❌ Error reading 'fap-nation.txt': {e}")
- return False
-
- if not urls_to_download:
- QMessageBox.information(self, "Empty File", "No valid Fap-Nation album URLs were found in 'fap-nation.txt'.")
- self.log_signal.emit(" 'fap-nation.txt' was found but contained no valid URLs.")
- return False
-
- self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
- self.favorite_download_queue.clear()
- for url in urls_to_download:
- self.favorite_download_queue.append({
- 'url': url,
- 'name': f"Fap-Nation link from batch",
- 'type': 'post'
- })
-
- if not self.is_processing_favorites_queue:
- self._process_next_favorite_download()
- return True
-
- is_saint2_url = 'saint2.su' in api_url or 'saint2.pk' in api_url
- if is_saint2_url:
- if api_url.strip().lower() != 'saint2.su':
- self.log_signal.emit("ℹ️ Saint2.su URL detected. Starting dedicated Saint2 download.")
- self.set_ui_enabled(False)
- self.download_thread = Saint2DownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if api_url.strip().lower() == 'saint2.su':
- self.log_signal.emit("=" * 40)
- self.log_signal.emit("🚀 Saint2.su batch download mode detected.")
-
- saint2_txt_path = os.path.join(self.user_data_path, "saint2.su.txt")
- self.log_signal.emit(f" Looking for batch file at: {saint2_txt_path}")
-
- if not os.path.exists(saint2_txt_path):
- QMessageBox.warning(self, "File Not Found", f"To use batch mode, create a file named 'saint2.su.txt' in your 'appdata' folder.\n\nPlace one saint2.su URL on each line.")
- self.log_signal.emit(f" ❌ 'saint2.su.txt' not found. Aborting batch download.")
- return False
-
- urls_to_download = []
- try:
- with open(saint2_txt_path, 'r', encoding='utf-8') as f:
- for line in f:
- # Find valid saint2 URLs in the line
- found_urls = re.findall(r'https?://saint\d*\.(?:su|pk|cr|to)/(?:a|d|embed)/[^/?#\s]+', line)
- if found_urls:
- urls_to_download.extend(found_urls)
- except Exception as e:
- QMessageBox.critical(self, "File Error", f"Could not read 'saint2.su.txt':\n{e}")
- self.log_signal.emit(f" ❌ Error reading 'saint2.su.txt': {e}")
- return False
-
- if not urls_to_download:
- QMessageBox.information(self, "Empty File", "No valid saint2.su URLs were found in 'saint2.su.txt'.")
- self.log_signal.emit(" 'saint2.su.txt' was found but contained no valid URLs.")
- return False
-
- self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
- self.favorite_download_queue.clear()
- for url in urls_to_download:
- self.favorite_download_queue.append({
- 'url': url,
- 'name': f"saint2.su link from batch",
- 'type': 'post' # Treat each URL as a single post-like item
- })
-
- if not self.is_processing_favorites_queue:
- self._process_next_favorite_download()
- return True
-
- if api_url.strip().lower() in ['hentai2read.com', 'https://hentai2read.com', 'http://hentai2read.com']:
- self.log_signal.emit("=" * 40)
- self.log_signal.emit("🚀 Hentai2Read batch download mode detected.")
-
- h2r_txt_path = os.path.join(self.user_data_path, "hentai2read.txt")
- self.log_signal.emit(f" Looking for batch file at: {h2r_txt_path}")
-
- if not os.path.exists(h2r_txt_path):
- QMessageBox.warning(self, "File Not Found", f"To use batch mode, create a file named 'hentai2read.txt' in your 'appdata' folder.\n\nPlace one Hentai2Read URL (series or chapter) on each line.")
- self.log_signal.emit(f" ❌ 'hentai2read.txt' not found. Aborting batch download.")
- return False
-
- urls_to_download = []
- try:
- with open(h2r_txt_path, 'r', encoding='utf-8') as f:
- for line in f:
- # This regex finds both series URLs (.../name/) and chapter URLs (.../name/1/)
- found_urls = re.findall(r'https?://hentai2read\.com/[^/\s]+(?:/\d+)?/?', line)
- if found_urls:
- urls_to_download.extend(found_urls)
- except Exception as e:
- QMessageBox.critical(self, "File Error", f"Could not read 'hentai2read.txt':\n{e}")
- self.log_signal.emit(f" ❌ Error reading 'hentai2read.txt': {e}")
- return False
-
- if not urls_to_download:
- QMessageBox.information(self, "Empty File", "No valid Hentai2Read URLs were found in 'hentai2read.txt'.")
- self.log_signal.emit(" 'hentai2read.txt' was found but contained no valid URLs.")
- return False
-
- self.log_signal.emit(f" Found {len(urls_to_download)} URLs to process.")
- self.favorite_download_queue.clear()
- for url in urls_to_download:
- self.favorite_download_queue.append({
- 'url': url,
- 'name': f"Hentai2Read link from batch",
- 'type': 'post' # Treat each URL as a generic item for the queue
- })
-
- if not self.is_processing_favorites_queue:
- self._process_next_favorite_download()
- return True
-
if not is_restore:
self._create_initial_session_file(api_url, effective_output_dir_for_run, remaining_queue=self.favorite_download_queue)
@@ -3942,334 +3738,41 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False
+ # START of the new refactored block
service, id1, id2 = extract_post_info(api_url)
- if service == 'simpcity':
- self.log_signal.emit("ℹ️ SimpCity URL detected. Preparing dedicated downloader...")
-
- cookies = prepare_cookies_for_request(
- use_cookie_flag=True,
- cookie_text_input=self.cookie_text_input.text(),
- selected_cookie_file_path=self.selected_cookie_filepath,
- app_base_dir=self.app_base_dir,
- logger_func=self.log_signal.emit,
- target_domain='simpcity.cr'
- )
+ specialized_thread = create_downloader_thread(
+ main_app=self,
+ api_url=api_url,
+ service=service,
+ id1=id1,
+ id2=id2,
+ effective_output_dir_for_run=effective_output_dir_for_run
+ )
- if not cookies:
+ if specialized_thread:
+ if specialized_thread == "COOKIE_ERROR":
+ from .dialogs.CookieHelpDialog import CookieHelpDialog
cookie_dialog = CookieHelpDialog(self, offer_download_without_option=False)
cookie_dialog.exec_()
-
return False
- service, thread_info, post_id = extract_post_info(api_url)
- self.set_ui_enabled(False)
-
- self.download_thread = SimpCityDownloadThread(api_url, post_id, effective_output_dir_for_run, cookies, self)
-
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.overall_progress_signal.connect(
- lambda total, processed: self.update_progress_display(total, processed, unit="files")
- )
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled, kept_names: self.download_finished(dl, skip, cancelled, kept_names)
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if 'discord.com' in api_url and service == 'discord':
- server_id, channel_id = id1, id2
- token = self.remove_from_filename_input.text().strip()
- output_dir = self.dir_input.text().strip()
-
- if not token or not output_dir:
- QMessageBox.critical(self, "Input Error", "A Discord Token and Download Location are required.")
- return False
-
- limit_text = self.discord_message_limit_input.text().strip()
- message_limit = int(limit_text) if limit_text else None
- if message_limit:
- self.log_signal.emit(f"ℹ️ Applying message limit: will fetch up to {message_limit} latest messages.")
-
- mode = 'pdf' if self.discord_download_scope == 'messages' else 'files'
-
- # 1. Create the thread object
- self.download_thread = DiscordDownloadThread(
- mode=mode, session=requests.Session(), token=token, output_dir=output_dir,
- server_id=server_id, channel_id=channel_id, url=api_url, limit=message_limit, parent=self
- )
-
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.progress_label_signal.connect(self.progress_label.setText)
- self.download_thread.finished_signal.connect(self.download_finished)
-
- self.download_thread.start()
-
- self.set_ui_enabled(False)
- self._update_button_states_and_connections()
-
- return True
-
- if 'allcomic.com' in api_url or 'allporncomic.com' in api_url:
- self.log_signal.emit("ℹ️ AllPornComic URL detected. Starting dedicated downloader.")
- self.set_ui_enabled(False)
-
- self.download_thread = AllcomicDownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.overall_progress_signal.connect(lambda total, processed: self.update_progress_display(total, processed, unit="pages"))
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
-
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if 'hentai2read.com' in api_url:
- self.log_signal.emit("ℹ️ Hentai2Read URL detected. Starting dedicated downloader.")
- self.set_ui_enabled(False)
-
- self.download_thread = Hentai2readDownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.overall_progress_signal.connect(lambda total, processed: self.update_progress_display(total, processed, unit="chapters"))
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
-
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if 'fap-nation.com' in api_url or 'fap-nation.org' in api_url:
- self.log_signal.emit("ℹ️ Fap-Nation URL detected. Starting dedicated Fap-Nation download.")
- self.set_ui_enabled(False)
- use_post_subfolder = self.use_subfolder_per_post_checkbox.isChecked()
- self.download_thread = FapNationDownloadThread(api_url, effective_output_dir_for_run, use_post_subfolder, self)
-
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.overall_progress_signal.connect(
- lambda total, processed: self.update_progress_display(total, processed, unit="files")
- )
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
-
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if 'pixeldrain.com' in api_url:
- self.log_signal.emit("ℹ️ Pixeldrain.com URL detected. Starting dedicated downloader.")
- self.set_ui_enabled(False)
-
- self.download_thread = PixeldrainDownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
-
- if service == 'nhentai':
- gallery_id = id1
- self.log_signal.emit("=" * 40)
- self.log_signal.emit(f"🚀 Detected nhentai gallery ID: {gallery_id}")
-
- if not effective_output_dir_for_run or not os.path.isdir(effective_output_dir_for_run):
- QMessageBox.critical(self, "Input Error", "A valid Download Location is required.")
- return False
-
- gallery_data = fetch_nhentai_gallery(gallery_id, self.log_signal.emit)
- if not gallery_data:
- QMessageBox.critical(self, "Error", f"Could not retrieve gallery data for ID {gallery_id}.")
+ if specialized_thread == "FETCH_ERROR":
+ QMessageBox.critical(self, "Error", "Could not retrieve gallery data for the provided URL.")
return False
self.set_ui_enabled(False)
- self.download_thread = NhentaiDownloadThread(gallery_data, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if 'toonily.com' in api_url:
- self.log_signal.emit("ℹ️ Toonily.com URL detected. Starting dedicated Toonily download.")
- self.set_ui_enabled(False)
-
- self.download_thread = ToonilyDownloadThread(api_url, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
-
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
- if service == 'bunkr':
- self.log_signal.emit("ℹ️ Bunkr URL detected. Starting dedicated Bunkr download.")
- self.set_ui_enabled(False)
-
- self.download_thread = BunkrDownloadThread(id1, effective_output_dir_for_run, self)
- self.download_thread.progress_signal.connect(self.handle_main_log)
- self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
- self.download_thread.finished_signal.connect(
- lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
- )
+ self.download_thread = specialized_thread
+ self._connect_specialized_thread_signals(self.download_thread)
self.download_thread.start()
self._update_button_states_and_connections()
return True
+ # END of the new refactored block
if not service or not id1:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
- if service == 'discord':
- server_id, channel_id = id1, id2
-
- def discord_processing_task():
- try:
- def queue_logger(message):
- self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)})
-
- def queue_progress_label_update(message):
- self.worker_to_gui_queue.put({'type': 'set_progress_label', 'payload': (message,)})
-
- cookies = prepare_cookies_for_request(
- self.use_cookie_checkbox.isChecked(), self.cookie_text_input.text(),
- self.selected_cookie_filepath, self.app_base_dir, queue_logger
- )
-
- if self.discord_download_scope == 'messages':
- queue_logger("=" * 40)
- queue_logger(f"🚀 Starting Discord PDF export for: {api_url}")
-
- output_dir = self.dir_input.text().strip()
- if not output_dir or not os.path.isdir(output_dir):
- queue_logger("❌ PDF Save Error: No valid download directory selected in the UI.")
- self.worker_to_gui_queue.put({'type': 'set_ui_enabled', 'payload': (True,)})
- return
-
- default_filename = f"discord_{server_id}_{channel_id or 'server'}.pdf"
- output_filepath = os.path.join(output_dir, default_filename)
-
- all_messages, channels_to_process = [], []
- server_name_for_pdf = server_id
-
- if channel_id:
- channels_to_process.append({'id': channel_id, 'name': channel_id})
- else:
- channels = fetch_server_channels(server_id, queue_logger, cookies)
- if channels:
- channels_to_process = channels
-
- for i, channel in enumerate(channels_to_process):
- queue_progress_label_update(f"Fetching from channel {i+1}/{len(channels_to_process)}: #{channel.get('name', '')}")
- message_generator = fetch_channel_messages(channel['id'], queue_logger, self.cancellation_event, self.pause_event, cookies)
- for message_batch in message_generator:
- all_messages.extend(message_batch)
-
- queue_progress_label_update(f"Collected {len(all_messages)} total messages. Generating PDF...")
-
- if getattr(sys, 'frozen', False):
- base_path = sys._MEIPASS
- else:
- base_path = self.app_base_dir
- font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
-
- success = create_pdf_from_discord_messages(
- all_messages, server_name_for_pdf,
- channels_to_process[0].get('name', channel_id) if len(channels_to_process) == 1 else "All Channels",
- output_filepath, font_path, logger=queue_logger
- )
-
- if success:
- queue_progress_label_update("✅ PDF export complete!")
- else:
- queue_progress_label_update("❌ PDF export failed.")
- self.finished_signal.emit(0, len(all_messages), self.cancellation_event.is_set(), [])
- return
-
- elif self.discord_download_scope == 'files':
- worker_args = {
- 'download_root': effective_output_dir_for_run, 'known_names': list(KNOWN_NAMES),
- 'filter_character_list': self._parse_character_filters(self.character_input.text().strip())[0],
- 'emitter': self.worker_to_gui_queue, 'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
- 'filter_mode': self.get_filter_mode(), 'skip_zip': self.skip_zip_checkbox.isChecked(),
- 'use_subfolders': self.use_subfolders_checkbox.isChecked(), 'use_post_subfolders': self.use_subfolder_per_post_checkbox.isChecked(),
- 'target_post_id_from_initial_url': None, 'custom_folder_name': None,
- 'compress_images': self.compress_images_checkbox.isChecked(), 'download_thumbnails': self.download_thumbnails_checkbox.isChecked(),
- 'service': service, 'user_id': server_id, 'api_url_input': api_url,
- 'pause_event': self.pause_event, 'cancellation_event': self.cancellation_event,
- 'downloaded_files': self.downloaded_files, 'downloaded_file_hashes': self.downloaded_file_hashes,
- 'downloaded_files_lock': self.downloaded_files_lock, 'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
- 'skip_words_list': [part.strip().lower() for part in self.skip_words_input.text().strip().split(',') if part.strip() and not part.strip().startswith('[')],
- 'skip_file_size_mb': next((int(re.search(r'\[(\d+)\]', part).group(1)) for part in self.skip_words_input.text().strip().split(',') if re.fullmatch(r'\[\d+\]', part.strip())), None),
- 'skip_words_scope': self.get_skip_words_scope(), 'char_filter_scope': self.get_char_filter_scope(),
- 'remove_from_filename_words_list': [word.strip() for word in self.remove_from_filename_input.text().strip().split(',') if word.strip()],
- 'scan_content_for_images': self.scan_content_images_checkbox.isChecked(),
- 'manga_mode_active': False,
- }
- total_dl, total_skip = 0, 0
-
- def process_channel_files(channel_id_to_process, output_directory):
- nonlocal total_dl, total_skip
- message_generator = fetch_channel_messages(channel_id_to_process, queue_logger, self.cancellation_event, self.pause_event, cookies)
- for message_batch in message_generator:
- if self.cancellation_event.is_set():
- break
- for message in message_batch:
- if self.cancellation_event.is_set():
- break
- if not message.get('attachments'):
- continue
-
- worker_instance_args = worker_args.copy()
- worker_instance_args.update({'post_data': message, 'download_root': output_directory, 'override_output_dir': output_directory})
- worker = PostProcessorWorker(**worker_instance_args)
- dl_count, skip_count, _, _, _, _, _ = worker.process()
- total_dl += dl_count
- total_skip += skip_count
-
- if channel_id:
- process_channel_files(channel_id, effective_output_dir_for_run)
- else:
- channels = fetch_server_channels(server_id, queue_logger, cookies)
- if channels:
- for i, channel in enumerate(channels):
- if self.cancellation_event.is_set():
- break
- chan_id = channel.get('id')
- chan_name = channel.get('name', f"channel_{chan_id}")
- queue_logger("=" * 40)
- queue_logger(f"Processing Channel {i+1}/{len(channels)}: '{chan_name}'")
- channel_dir = os.path.join(effective_output_dir_for_run, clean_folder_name(chan_name))
- os.makedirs(channel_dir, exist_ok=True)
- process_channel_files(chan_id, channel_dir)
-
- self.finished_signal.emit(total_dl, total_skip, self.cancellation_event.is_set(), [])
- finally:
- self.is_fetcher_thread_running = False
-
- self.is_fetcher_thread_running = True
-
- self.set_ui_enabled(False)
- self.download_thread = threading.Thread(target=discord_processing_task, daemon=True)
- self.download_thread.start()
- self._update_button_states_and_connections()
- return True
-
user_id, post_id_from_url = id1, id2
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue:
@@ -4716,7 +4219,6 @@ class DownloaderApp (QWidget ):
if manga_mode:
log_messages.append(f" Renaming Mode: Enabled")
- # Create a mapping for user-friendly style names
style_display_names = {
STYLE_POST_TITLE: "Post Title",
STYLE_ORIGINAL_NAME: "Date + Original",
@@ -4912,7 +4414,6 @@ class DownloaderApp (QWidget ):
if self.pause_event: self.pause_event.clear()
self.is_paused = False
return True
-
def restore_download(self):
"""Initiates the download restoration process."""
if self._is_download_active():
@@ -6794,7 +6295,7 @@ class DownloaderApp (QWidget ):
service = creator_data.get('service')
creator_id = creator_data.get('id')
creator_name = creator_data.get('name', 'Unknown Creator')
- domain = dialog._get_domain_for_service(service)
+ domain = self._get_domain_for_service(service)
if service and creator_id:
url = f"https://{domain}/{service}/user/{creator_id}"
@@ -7020,30 +6521,33 @@ class DownloaderApp (QWidget ):
next_url =self .current_processing_favorite_item_info ['url']
item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item')
- # --- MODIFIED SECTION START ---
item_type = self.current_processing_favorite_item_info.get('type', 'artist')
self.log_signal.emit(f"▶️ Processing next favorite from queue ({item_type}): '{item_display_name}' ({next_url})")
- # --- FIX: Check for Fap-Nation and use its dedicated thread ---
if 'fap-nation.com' in next_url or 'fap-nation.org' in next_url:
self.log_signal.emit(" (Fap-Nation batch item detected, using dedicated thread)")
output_dir = self.dir_input.text().strip()
use_post_subfolder = self.use_subfolder_per_post_checkbox.isChecked()
- # This ensures the correct thread with the HLS-first logic is always used
- self.download_thread = FapNationDownloadThread(next_url, output_dir, use_post_subfolder, self)
+ self.download_thread = FapNationDownloadThread(
+ next_url,
+ output_dir,
+ use_post_subfolder,
+ self.pause_event,
+ self.cancellation_event,
+ self.actual_gui_signals,
+ self
+ )
self.download_thread.progress_signal.connect(self.handle_main_log)
self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
self.download_thread.overall_progress_signal.connect(
lambda total, processed: self.update_progress_display(total, processed, unit="files")
)
- # When this thread finishes, it will automatically call _process_next_favorite_download
self.download_thread.finished_signal.connect(
lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
)
self.download_thread.start()
self._update_button_states_and_connections()
- return # End processing for this item
- # --- END OF FIX ---
+ return
override_dir = None
item_scope = self.current_processing_favorite_item_info.get('scope_from_popup')
@@ -7070,1746 +6574,7 @@ class DownloaderApp (QWidget ):
is_continuation=True,
item_type_from_queue=item_type
)
- # --- END MODIFIED SECTION ---
if not success_starting_download:
self.log_signal.emit(f"⚠️ Failed to initiate download for '{item_display_name}'. Skipping and moving to the next item in queue.")
- # Use a QTimer to avoid deep recursion and correctly move to the next item.
- QTimer.singleShot(100, self._process_next_favorite_download)
-
-class DiscordDownloadThread(QThread):
- """A dedicated QThread for handling all official Discord downloads."""
- progress_signal = pyqtSignal(str)
- progress_label_signal = pyqtSignal(str)
- finished_signal = pyqtSignal(int, int, bool, list)
-
- def __init__(self, mode, session, token, output_dir, server_id, channel_id, url, limit=None, parent=None):
- super().__init__(parent)
- self.mode = mode
- self.session = session
- self.token = token
- self.output_dir = output_dir
- self.server_id = server_id
- self.channel_id = channel_id
- self.api_url = url
- self.message_limit = limit
-
- self.is_cancelled = False
- self.is_paused = False
-
- def run(self):
- if self.mode == 'pdf':
- self._run_pdf_creation()
- else:
- self._run_file_download()
-
- def cancel(self):
- self.progress_signal.emit(" Cancellation signal received by Discord thread.")
- self.is_cancelled = True
-
- def pause(self):
- self.progress_signal.emit(" Pausing Discord download...")
- self.is_paused = True
-
- def resume(self):
- self.progress_signal.emit(" Resuming Discord download...")
- self.is_paused = False
-
- def _check_events(self):
- if self.is_cancelled:
- return True
- while self.is_paused:
- time.sleep(0.5)
- if self.is_cancelled:
- return True
- return False
-
- def _fetch_all_messages(self):
- all_messages = []
- last_message_id = None
- headers = {'Authorization': self.token, 'User-Agent': USERAGENT_FIREFOX}
-
- while True:
- if self._check_events(): break
-
- endpoint = f"/channels/{self.channel_id}/messages?limit=100"
- if last_message_id:
- endpoint += f"&before={last_message_id}"
-
- try:
- # This is a blocking call, but it has a timeout
- resp = self.session.get(f"https://discord.com/api/v10{endpoint}", headers=headers, timeout=30)
- resp.raise_for_status()
- message_batch = resp.json()
- except Exception as e:
- self.progress_signal.emit(f" ❌ Error fetching message batch: {e}")
- break
-
- if not message_batch:
- break
-
- all_messages.extend(message_batch)
-
- if self.message_limit and len(all_messages) >= self.message_limit:
- self.progress_signal.emit(f" Reached message limit of {self.message_limit}. Halting fetch.")
- all_messages = all_messages[:self.message_limit]
- break
-
- last_message_id = message_batch[-1]['id']
- self.progress_label_signal.emit(f"Fetched {len(all_messages)} messages...")
- time.sleep(1) # API Rate Limiting
-
- return all_messages
-
- def _run_pdf_creation(self):
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Discord PDF export for: {self.api_url}")
- self.progress_label_signal.emit("Fetching messages...")
-
- all_messages = self._fetch_all_messages()
-
- if self.is_cancelled:
- self.finished_signal.emit(0, 0, True, [])
- return
-
- self.progress_label_signal.emit(f"Collected {len(all_messages)} total messages. Generating PDF...")
- all_messages.reverse()
-
- base_path = self.parent().app_base_dir
- font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
- output_filepath = os.path.join(self.output_dir, f"discord_{self.server_id}_{self.channel_id or 'server'}.pdf")
-
- # The PDF generator itself now also checks for events
- success = create_pdf_from_discord_messages(
- all_messages, self.server_id, self.channel_id,
- output_filepath, font_path, logger=self.progress_signal.emit,
- cancellation_event=self, pause_event=self
- )
-
- if success:
- self.progress_label_signal.emit(f"✅ PDF export complete!")
- elif not self.is_cancelled:
- self.progress_label_signal.emit(f"❌ PDF export failed. Check log for details.")
-
- self.finished_signal.emit(0, len(all_messages), self.is_cancelled, [])
-
- def _run_file_download(self):
- download_count = 0
- skip_count = 0
- try:
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Discord download for channel: {self.channel_id}")
- self.progress_label_signal.emit("Fetching messages...")
- all_messages = self._fetch_all_messages()
-
- if self.is_cancelled:
- self.finished_signal.emit(0, 0, True, [])
- return
-
- self.progress_label_signal.emit(f"Collected {len(all_messages)} messages. Starting downloads...")
- total_attachments = sum(len(m.get('attachments', [])) for m in all_messages)
-
- for message in reversed(all_messages):
- if self._check_events(): break
- for attachment in message.get('attachments', []):
- if self._check_events(): break
-
- file_url = attachment['url']
- original_filename = attachment['filename']
- filepath = os.path.join(self.output_dir, original_filename)
- filename_to_use = original_filename
-
- counter = 1
- base_name, extension = os.path.splitext(original_filename)
- while os.path.exists(filepath):
- filename_to_use = f"{base_name} ({counter}){extension}"
- filepath = os.path.join(self.output_dir, filename_to_use)
- counter += 1
-
- if filename_to_use != original_filename:
- self.progress_signal.emit(f" -> Duplicate name '{original_filename}'. Saving as '{filename_to_use}'.")
-
- try:
- self.progress_signal.emit(f" Downloading ({download_count+1}/{total_attachments}): '{filename_to_use}'...")
- response = requests.get(file_url, stream=True, timeout=60)
- response.raise_for_status()
-
- download_cancelled_mid_file = False
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self._check_events():
- download_cancelled_mid_file = True
- break
- f.write(chunk)
-
- if download_cancelled_mid_file:
- self.progress_signal.emit(f" Download cancelled for '{filename_to_use}'. Deleting partial file.")
- if os.path.exists(filepath):
- os.remove(filepath)
- continue
-
- download_count += 1
- except Exception as e:
- self.progress_signal.emit(f" ❌ Failed to download '{filename_to_use}': {e}")
- skip_count += 1
- finally:
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled, [])
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Discord thread.")
-
-class Saint2DownloadThread(QThread):
- """A dedicated QThread for handling saint2.su downloads."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.saint2_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
-
- def run(self):
- download_count = 0
- skip_count = 0
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Saint2.su Download for: {self.saint2_url}")
-
- # Use the new client to get the download info
- album_name, files_to_download = fetch_saint2_data(self.saint2_url, self.progress_signal.emit)
-
- if not files_to_download:
- self.progress_signal.emit("❌ Failed to extract file information from Saint2. Aborting.")
- self.finished_signal.emit(0, 0, self.is_cancelled)
- return
-
- # For single media, album_name is the title; for albums, it's the album title
- album_path = os.path.join(self.output_dir, album_name)
- try:
- os.makedirs(album_path, exist_ok=True)
- self.progress_signal.emit(f" Saving to folder: '{album_path}'")
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- self.finished_signal.emit(0, len(files_to_download), self.is_cancelled)
- return
-
- total_files = len(files_to_download)
- session = requests.Session()
-
- for i, file_data in enumerate(files_to_download):
- if self.is_cancelled:
- self.progress_signal.emit(" Download cancelled by user.")
- skip_count = total_files - download_count
- break
-
- filename = file_data.get('filename', f'untitled_{i+1}.mp4')
- file_url = file_data.get('url')
- headers = file_data.get('headers')
- filepath = os.path.join(album_path, filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
- skip_count += 1
- continue
-
- self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
-
- try:
- response = session.get(file_url, stream=True, headers=headers, timeout=60)
- response.raise_for_status()
-
- total_size = int(response.headers.get('content-length', 0))
- downloaded_size = 0
- last_update_time = time.time()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled:
- break
- if chunk:
- f.write(chunk)
- downloaded_size += len(chunk)
- current_time = time.time()
- if total_size > 0 and (current_time - last_update_time) > 0.5:
- self.file_progress_signal.emit(filename, (downloaded_size, total_size))
- last_update_time = current_time
-
- if self.is_cancelled:
- if os.path.exists(filepath): os.remove(filepath)
- continue
-
- if total_size > 0:
- self.file_progress_signal.emit(filename, (total_size, total_size))
-
- download_count += 1
- except requests.exceptions.RequestException as e:
- self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
- except Exception as e:
- self.progress_signal.emit(f" ❌ An unexpected error occurred with '{filename}': {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
-
- self.file_progress_signal.emit("", None)
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Saint2 thread.")
-
-class EromeDownloadThread(QThread):
- """A dedicated QThread for handling erome.com downloads."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.erome_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
-
- def run(self):
- download_count = 0
- skip_count = 0
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Erome.com Download for: {self.erome_url}")
-
- album_name, files_to_download = fetch_erome_data(self.erome_url, self.progress_signal.emit)
-
- if not files_to_download:
- self.progress_signal.emit("❌ Failed to extract file information from Erome. Aborting.")
- self.finished_signal.emit(0, 0, self.is_cancelled)
- return
-
- album_path = os.path.join(self.output_dir, album_name)
- try:
- os.makedirs(album_path, exist_ok=True)
- self.progress_signal.emit(f" Saving to folder: '{album_path}'")
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- self.finished_signal.emit(0, len(files_to_download), self.is_cancelled)
- return
-
- total_files = len(files_to_download)
- session = cloudscraper.create_scraper()
-
- for i, file_data in enumerate(files_to_download):
- if self.is_cancelled:
- self.progress_signal.emit(" Download cancelled by user.")
- skip_count = total_files - download_count
- break
-
- filename = file_data.get('filename', f'untitled_{i+1}.mp4')
- file_url = file_data.get('url')
- headers = file_data.get('headers')
- filepath = os.path.join(album_path, filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
- skip_count += 1
- continue
-
- self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
-
- try:
- response = session.get(file_url, stream=True, headers=headers, timeout=60)
- response.raise_for_status()
-
- total_size = int(response.headers.get('content-length', 0))
- downloaded_size = 0
- last_update_time = time.time()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled:
- break
- if chunk:
- f.write(chunk)
- downloaded_size += len(chunk)
- current_time = time.time()
- if total_size > 0 and (current_time - last_update_time) > 0.5:
- self.file_progress_signal.emit(filename, (downloaded_size, total_size))
- last_update_time = current_time
-
- if self.is_cancelled:
- if os.path.exists(filepath): os.remove(filepath)
- continue
-
- if total_size > 0:
- self.file_progress_signal.emit(filename, (total_size, total_size))
-
- download_count += 1
- except requests.exceptions.RequestException as e:
- self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
- except Exception as e:
- self.progress_signal.emit(f" ❌ An unexpected error occurred with '{filename}': {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
-
- self.file_progress_signal.emit("", None)
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Erome thread.")
-
-class FapNationDownloadThread(QThread):
- """
- A dedicated QThread for Fap-Nation that uses a hybrid approach, choosing
- between yt-dlp for HLS streams and a multipart downloader for direct links.
- """
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool)
- overall_progress_signal = pyqtSignal(int, int)
-
- def __init__(self, url, output_dir, use_post_subfolder, parent=None):
- super().__init__(parent)
- self.album_url = url
- self.output_dir = output_dir
- self.use_post_subfolder = use_post_subfolder
- self.is_cancelled = False
- self.process = None
- self.current_filename = "Unknown File"
- self.album_name = "fap-nation_album"
- self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
- self._is_finished = False
-
- self.process = QProcess(self)
- self.process.readyReadStandardOutput.connect(self.handle_ytdlp_output)
-
- def run(self):
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Fap-Nation Download for: {self.album_url}")
-
- self.album_name, files_to_download = fetch_fap_nation_data(self.album_url, self.progress_signal.emit)
-
- if self.is_cancelled or not files_to_download:
- self.progress_signal.emit("❌ Failed to extract file information. Aborting.")
- self.finished_signal.emit(0, 1, self.is_cancelled)
- return
-
- self.overall_progress_signal.emit(1, 0)
-
- # --- Conditionally set the save path based on the UI checkbox ---
- save_path = self.output_dir
- if self.use_post_subfolder:
- save_path = os.path.join(self.output_dir, self.album_name)
- self.progress_signal.emit(f" Subfolder per Post is ON. Saving to: '{self.album_name}'")
- os.makedirs(save_path, exist_ok=True)
- # --- End of change ---
-
- file_data = files_to_download[0]
- self.current_filename = file_data.get('filename')
- download_url = file_data.get('url')
- link_type = file_data.get('type')
- filepath = os.path.join(save_path, self.current_filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip: '{self.current_filename}' already exists.")
- self.overall_progress_signal.emit(1, 1)
- self.finished_signal.emit(0, 1, self.is_cancelled)
- return
-
- if link_type == 'hls':
- self.download_with_ytdlp(filepath, download_url)
- elif link_type == 'direct':
- self.download_with_multipart(filepath, download_url)
- else:
- self.progress_signal.emit(f" ❌ Unknown link type '{link_type}'. Aborting.")
- self._on_ytdlp_finished(-1)
-
- def download_with_ytdlp(self, filepath, playlist_url):
- self.progress_signal.emit(f" Downloading (HLS Stream): '{self.current_filename}' using yt-dlp...")
- try:
- if getattr(sys, 'frozen', False):
- # When the app is a frozen executable, find yt-dlp in the temp folder
- base_path = sys._MEIPASS
- ytdlp_path = os.path.join(base_path, "yt-dlp.exe")
- else:
- # In a normal script environment, find it in the project root
- ytdlp_path = "yt-dlp.exe"
-
- if not os.path.exists(ytdlp_path):
- self.progress_signal.emit(f" ❌ ERROR: yt-dlp.exe not found at '{ytdlp_path}'.")
- self._on_ytdlp_finished(-1)
- return
-
- command = [ytdlp_path, '--no-warnings', '--progress', '--output', filepath, '--merge-output-format', 'mp4', playlist_url]
-
- self.process.start(command[0], command[1:])
- self.process.waitForFinished(-1)
- self._on_ytdlp_finished(self.process.exitCode())
-
- except Exception as e:
- self.progress_signal.emit(f" ❌ Failed to start yt-dlp: {e}")
- self._on_ytdlp_finished(-1)
-
- def download_with_multipart(self, filepath, direct_url):
- self.progress_signal.emit(f" Downloading (Direct Link): '{self.current_filename}' using multipart downloader...")
- try:
- session = cloudscraper.create_scraper()
- head_response = session.head(direct_url, allow_redirects=True, timeout=20)
- head_response.raise_for_status()
- total_size = int(head_response.headers.get('content-length', 0))
-
- success, _, _, _ = download_file_in_parts(
- file_url=direct_url, save_path=filepath, total_size=total_size, num_parts=5,
- headers=session.headers, api_original_filename=self.current_filename,
- emitter_for_multipart=self.parent().actual_gui_signals,
- cookies_for_chunk_session=session.cookies,
- cancellation_event=self.parent().cancellation_event,
- skip_event=None, logger_func=self.progress_signal.emit, pause_event=self.pause_event
- )
- self._on_ytdlp_finished(0 if success else 1)
- except Exception as e:
- self.progress_signal.emit(f" ❌ Multipart download failed: {e}")
- self._on_ytdlp_finished(1)
-
- def handle_ytdlp_output(self):
- if not self.process:
- return
-
- output = self.process.readAllStandardOutput().data().decode('utf-8', errors='ignore')
- for line in reversed(output.strip().splitlines()):
- line = line.strip()
- progress_match = re.search(r'\[download\]\s+([\d.]+)%\s+of\s+~?\s*([\d.]+\w+B)', line)
- if progress_match:
- percent, size = progress_match.groups()
- self.file_progress_signal.emit("yt-dlp:", f"{percent}% of {size}")
- break
-
- def _on_ytdlp_finished(self, exit_code):
- if self._is_finished:
- return
- self._is_finished = True
-
- download_count, skip_count = 0, 0
-
- if self.is_cancelled:
- self.progress_signal.emit(f" Download of '{self.current_filename}' was cancelled.")
- skip_count = 1
- elif exit_code == 0:
- self.progress_signal.emit(f" ✅ Download process finished successfully for '{self.current_filename}'.")
- download_count = 1
- else:
- self.progress_signal.emit(f" ❌ Download process exited with an error (Code: {exit_code}) for '{self.current_filename}'.")
- skip_count = 1
-
- self.overall_progress_signal.emit(1, 1)
- self.process = None
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- if self.process and self.process.state() == QProcess.Running:
- self.progress_signal.emit(" Cancellation signal received, terminating yt-dlp process.")
- self.process.kill()
-
-class SimpCityDownloadThread(QThread):
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool, list)
- overall_progress_signal = pyqtSignal(int, int)
-
- def __init__(self, url, post_id, output_dir, cookies, parent=None):
- super().__init__(parent)
- self.start_url = url
- self.post_id = post_id
- self.output_dir = output_dir
- self.cookies = cookies
- self.is_cancelled = False
- self.parent_app = parent
- self.image_queue = queue.Queue()
- self.service_queue = queue.Queue()
- self.counter_lock = threading.Lock()
- self.total_dl_count = 0
- self.total_skip_count = 0
- self.total_jobs_found = 0
- self.total_jobs_processed = 0
- self.processed_job_urls = set()
-
- def cancel(self):
- self.is_cancelled = True
-
- class _ServiceLoggerAdapter:
- """Wraps the progress signal to provide .info(), .error(), .warning() methods for other clients."""
- def __init__(self, signal_emitter, prefix=""):
- self.emit = signal_emitter
- self.prefix = prefix
- def info(self, msg, *args, **kwargs): self.emit(f"{self.prefix}{str(msg) % args}")
- def error(self, msg, *args, **kwargs): self.emit(f"{self.prefix}❌ ERROR: {str(msg) % args}")
- def warning(self, msg, *args, **kwargs): self.emit(f"{self.prefix}⚠️ WARNING: {str(msg) % args}")
-
- def _log_interceptor(self, message):
- """Filters out verbose log messages from the simpcity_client."""
- if "[SimpCity] Scraper found" in message or "[SimpCity] Scraping page" in message:
- pass
- else:
- self.progress_signal.emit(message)
-
- def _get_enriched_jobs(self, jobs_to_check):
- """Performs a pre-flight check on jobs to get an accurate total file count and summary."""
- if not jobs_to_check:
- return []
-
- enriched_jobs = []
-
- bunkr_logger = self._ServiceLoggerAdapter(self.progress_signal.emit, prefix=" ")
- pixeldrain_logger = self._ServiceLoggerAdapter(self.progress_signal.emit, prefix=" ")
- saint2_logger = self._ServiceLoggerAdapter(self.progress_signal.emit, prefix=" ")
-
- for job in jobs_to_check:
- job_type = job.get('type')
- job_url = job.get('url')
-
- if job_type in ['image', 'saint2_direct']:
- enriched_jobs.append(job)
- elif (job_type == 'bunkr' and self.should_dl_bunkr) or \
- (job_type == 'pixeldrain' and self.should_dl_pixeldrain) or \
- (job_type == 'saint2' and self.should_dl_saint2):
- self.progress_signal.emit(f" -> Checking {job_type} album for file count...")
-
- fetch_map = {
- 'bunkr': (fetch_bunkr_data, bunkr_logger),
- 'pixeldrain': (fetch_pixeldrain_data, pixeldrain_logger),
- 'saint2': (fetch_saint2_data, saint2_logger)
- }
- fetch_func, logger_adapter = fetch_map[job_type]
- album_name, files = fetch_func(job_url, logger_adapter)
-
- if files:
- job['prefetched_files'] = files
- job['prefetched_album_name'] = album_name
- enriched_jobs.append(job)
-
- if enriched_jobs:
- summary_counts = Counter()
- current_page_file_count = 0
- for job in enriched_jobs:
- if job.get('prefetched_files'):
- file_count = len(job['prefetched_files'])
- summary_counts[job['type']] += file_count
- current_page_file_count += file_count
- else:
- summary_counts[job['type']] += 1
- current_page_file_count += 1
-
- summary_parts = [f"{job_type} ({count})" for job_type, count in summary_counts.items()]
- self.progress_signal.emit(f" [SimpCity] Content Found: {' | '.join(summary_parts)}")
-
- with self.counter_lock: self.total_jobs_found += current_page_file_count
- self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
-
- return enriched_jobs
-
- def _download_single_image(self, job, album_path, session):
- """Downloads one image file; this is run by the image thread pool."""
- filename = job['filename']
- filepath = os.path.join(album_path, filename)
- try:
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip (Image): '{filename}'")
- with self.counter_lock: self.total_skip_count += 1
- return
- self.progress_signal.emit(f" -> Downloading (Image): '{filename}'...")
- response = session.get(job['url'], stream=True, timeout=90, headers={'Referer': self.start_url})
- response.raise_for_status()
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled: break
- f.write(chunk)
- if not self.is_cancelled:
- with self.counter_lock: self.total_dl_count += 1
- except Exception as e:
- self.progress_signal.emit(f" -> ❌ Image download failed for '{filename}': {e}")
- with self.counter_lock: self.total_skip_count += 1
- finally:
- if not self.is_cancelled:
- with self.counter_lock: self.total_jobs_processed += 1
- self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
-
- def _image_worker(self, album_path):
- """Target function for the image thread pool that pulls jobs from the queue."""
- session = cloudscraper.create_scraper()
- while True:
- if self.is_cancelled: break
- try:
- job = self.image_queue.get(timeout=1)
- if job is None: break
- self._download_single_image(job, album_path, session)
- self.image_queue.task_done()
- except queue.Empty:
- continue
-
- def _service_worker(self, album_path):
- """Target function for the single service thread, ensuring sequential downloads."""
- while True:
- if self.is_cancelled: break
- try:
- job = self.service_queue.get(timeout=1)
- if job is None: break
-
- job_type = job['type']
- job_url = job['url']
-
- if job_type in ['pixeldrain', 'saint2', 'bunkr']:
- if (job_type == 'pixeldrain' and self.should_dl_pixeldrain) or \
- (job_type == 'saint2' and self.should_dl_saint2) or \
- (job_type == 'bunkr' and self.should_dl_bunkr):
- self.progress_signal.emit(f"\n--- Processing Service ({job_type.capitalize()}): {job_url} ---")
- self._download_album(job.get('prefetched_files', []), job_url, album_path)
- elif job_type == 'mega' and self.should_dl_mega:
- self.progress_signal.emit(f"\n--- Processing Service (Mega): {job_url} ---")
- drive_download_mega_file(job_url, album_path, self.progress_signal.emit, self.file_progress_signal.emit)
- elif job_type == 'gofile' and self.should_dl_gofile:
- self.progress_signal.emit(f"\n--- Processing Service (Gofile): {job_url} ---")
- download_gofile_folder(job_url, album_path, self.progress_signal.emit, self.file_progress_signal.emit)
- elif job_type == 'saint2_direct' and self.should_dl_saint2:
- self.progress_signal.emit(f"\n--- Processing Service (Saint2 Direct): {job_url} ---")
- try:
- filename = os.path.basename(urlparse(job_url).path)
- filepath = os.path.join(album_path, filename)
- if os.path.exists(filepath):
- with self.counter_lock: self.total_skip_count += 1
- else:
- response = cloudscraper.create_scraper().get(job_url, stream=True, timeout=120, headers={'Referer': self.start_url})
- response.raise_for_status()
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled: break
- f.write(chunk)
- if not self.is_cancelled:
- with self.counter_lock: self.total_dl_count += 1
- except Exception as e:
- with self.counter_lock: self.total_skip_count += 1
- finally:
- if not self.is_cancelled:
- with self.counter_lock: self.total_jobs_processed += 1
- self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
-
- self.service_queue.task_done()
- except queue.Empty:
- continue
-
- def _download_album(self, files_to_process, source_url, album_path):
- """Helper to download all files from a pre-fetched album list."""
- if not files_to_process: return
- session = cloudscraper.create_scraper()
- for file_data in files_to_process:
- if self.is_cancelled: return
- filename = file_data.get('filename') or file_data.get('name')
- filepath = os.path.join(album_path, filename)
- try:
- if os.path.exists(filepath):
- with self.counter_lock: self.total_skip_count += 1
- else:
- self.progress_signal.emit(f" -> Downloading: '{filename}'...")
- headers = file_data.get('headers', {'Referer': source_url})
- response = session.get(file_data.get('url'), stream=True, timeout=90, headers=headers)
- response.raise_for_status()
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled: break
- f.write(chunk)
- if not self.is_cancelled:
- with self.counter_lock: self.total_dl_count += 1
- except Exception as e:
- with self.counter_lock: self.total_skip_count += 1
- finally:
- if not self.is_cancelled:
- with self.counter_lock: self.total_jobs_processed += 1
- self.overall_progress_signal.emit(self.total_jobs_found, self.total_jobs_processed)
-
- def run(self):
- """Main entry point for the thread, orchestrates the entire download."""
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting SimpCity Download for: {self.start_url}")
-
- self.should_dl_pixeldrain = self.parent_app.simpcity_dl_pixeldrain_cb.isChecked()
- self.should_dl_saint2 = self.parent_app.simpcity_dl_saint2_cb.isChecked()
- self.should_dl_mega = self.parent_app.simpcity_dl_mega_cb.isChecked()
- self.should_dl_bunkr = self.parent_app.simpcity_dl_bunkr_cb.isChecked()
- self.should_dl_gofile = self.parent_app.simpcity_dl_gofile_cb.isChecked()
-
- is_single_post_mode = self.post_id or '/post-' in self.start_url
- album_path = ""
-
- try:
- if is_single_post_mode:
- self.progress_signal.emit(" Mode: Single Post detected.")
- album_title, _ = fetch_single_simpcity_page(self.start_url, self._log_interceptor, cookies=self.cookies, post_id=self.post_id)
- album_path = os.path.join(self.output_dir, clean_folder_name(album_title or "simpcity_post"))
- else:
- self.progress_signal.emit(" Mode: Full Thread detected.")
- first_page_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
- album_title, _ = fetch_single_simpcity_page(first_page_url, self._log_interceptor, cookies=self.cookies)
- album_path = os.path.join(self.output_dir, clean_folder_name(album_title or "simpcity_album"))
- os.makedirs(album_path, exist_ok=True)
- self.progress_signal.emit(f" Saving all content to folder: '{os.path.basename(album_path)}'")
- except Exception as e:
- self.progress_signal.emit(f"❌ Could not process the initial page. Aborting. Error: {e}")
- self.finished_signal.emit(0, 0, self.is_cancelled, []); return
-
- service_thread = threading.Thread(target=self._service_worker, args=(album_path,), daemon=True)
- service_thread.start()
- num_image_threads = 15
- image_executor = ThreadPoolExecutor(max_workers=num_image_threads, thread_name_prefix='SimpCityImage')
- for _ in range(num_image_threads): image_executor.submit(self._image_worker, album_path)
-
- try:
- if is_single_post_mode:
- _, jobs = fetch_single_simpcity_page(self.start_url, self._log_interceptor, cookies=self.cookies, post_id=self.post_id)
- enriched_jobs = self._get_enriched_jobs(jobs)
- if enriched_jobs:
- for job in enriched_jobs:
- if job['type'] == 'image': self.image_queue.put(job)
- else: self.service_queue.put(job)
- else:
- base_url = re.sub(r'(/page-\d+)|(/post-\d+)', '', self.start_url).split('#')[0].strip('/')
- page_counter = 1; end_of_thread = False; MAX_RETRIES = 3
- while not end_of_thread:
- if self.is_cancelled: break
- page_url = f"{base_url}/page-{page_counter}"; retries = 0; page_fetch_successful = False
- while retries < MAX_RETRIES:
- if self.is_cancelled: end_of_thread = True; break
- self.progress_signal.emit(f"\n--- Analyzing page {page_counter} (Attempt {retries + 1}/{MAX_RETRIES}) ---")
- try:
- _, jobs_on_page = fetch_single_simpcity_page(page_url, self._log_interceptor, cookies=self.cookies)
- if not jobs_on_page: end_of_thread = True
- else:
- new_jobs = [job for job in jobs_on_page if job.get('url') not in self.processed_job_urls]
- if not new_jobs and page_counter > 1: end_of_thread = True
- else:
- enriched_jobs = self._get_enriched_jobs(new_jobs)
- for job in enriched_jobs:
- self.processed_job_urls.add(job.get('url'))
- if job['type'] == 'image': self.image_queue.put(job)
- else: self.service_queue.put(job)
- page_fetch_successful = True; break
- except requests.exceptions.HTTPError as e:
- if e.response.status_code in [403, 404]: end_of_thread = True; break
- elif e.response.status_code == 429: time.sleep(5 * (retries + 2)); retries += 1
- else: end_of_thread = True; break
- except Exception as e:
- self.progress_signal.emit(f" Stopping crawl due to error on page {page_counter}: {e}"); end_of_thread = True; break
- if not page_fetch_successful and not end_of_thread: end_of_thread = True
- if not end_of_thread: page_counter += 1
- except Exception as e:
- self.progress_signal.emit(f"❌ A critical error occurred during the main fetch phase: {e}")
-
- self.progress_signal.emit("\n--- All pages analyzed. Waiting for background downloads to complete... ---")
- for _ in range(num_image_threads): self.image_queue.put(None)
- self.service_queue.put(None)
- image_executor.shutdown(wait=True)
- service_thread.join()
- self.finished_signal.emit(self.total_dl_count, self.total_skip_count, self.is_cancelled, [])
-
-class AllcomicDownloadThread(QThread):
- """A dedicated QThread for handling allcomic.com downloads."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool)
- overall_progress_signal = pyqtSignal(int, int)
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.comic_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
- self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
-
- def _check_pause(self):
- if self.is_cancelled: return True
- if self.pause_event and self.pause_event.is_set():
- self.progress_signal.emit(" Download paused...")
- while self.pause_event.is_set():
- if self.is_cancelled: return True
- time.sleep(0.5)
- self.progress_signal.emit(" Download resumed.")
- return self.is_cancelled
-
- def run(self):
- grand_total_dl = 0
- grand_total_skip = 0
-
- chapters_to_download = allcomic_get_list(self.comic_url, self.progress_signal.emit)
-
- if not chapters_to_download:
- chapters_to_download = [self.comic_url]
-
- self.progress_signal.emit(f"--- Starting download of {len(chapters_to_download)} chapter(s) ---")
-
- for chapter_idx, chapter_url in enumerate(chapters_to_download):
- if self._check_pause(): break
-
- self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --")
- comic_title, chapter_title, image_urls = allcomic_fetch_data(chapter_url, self.progress_signal.emit)
-
- if not image_urls:
- self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
- continue
-
- series_folder_name = clean_folder_name(comic_title)
- chapter_folder_name = clean_folder_name(chapter_title)
- final_save_path = os.path.join(self.output_dir, series_folder_name, chapter_folder_name)
-
- try:
- os.makedirs(final_save_path, exist_ok=True)
- self.progress_signal.emit(f" Saving to folder: '{os.path.join(series_folder_name, chapter_folder_name)}'")
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- grand_total_skip += len(image_urls)
- continue
-
- total_files_in_chapter = len(image_urls)
- self.overall_progress_signal.emit(total_files_in_chapter, 0)
- scraper = cloudscraper.create_scraper()
- headers = {'Referer': chapter_url}
-
- for i, img_url in enumerate(image_urls):
- if self._check_pause(): break
-
- file_extension = os.path.splitext(urlparse(img_url).path)[1] or '.jpg'
- filename = f"{i+1:03d}{file_extension}"
- filepath = os.path.join(final_save_path, filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({i+1}/{total_files_in_chapter}): '{filename}' already exists.")
- grand_total_skip += 1
- else:
- download_successful = False
- max_retries = 8 # <-- MODIFIED
- for attempt in range(max_retries):
- if self._check_pause(): break
- try:
- self.progress_signal.emit(f" Downloading ({i+1}/{total_files_in_chapter}): '{filename}' (Attempt {attempt + 1})...")
- response = scraper.get(img_url, stream=True, headers=headers, timeout=60)
- response.raise_for_status()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self._check_pause(): break
- f.write(chunk)
-
- if self._check_pause():
- if os.path.exists(filepath): os.remove(filepath)
- break
-
- download_successful = True
- grand_total_dl += 1
- break
-
- except requests.RequestException as e:
- self.progress_signal.emit(f" ⚠️ Attempt {attempt + 1} failed for '{filename}': {e}")
- if attempt < max_retries - 1:
- wait_time = 2 * (attempt + 1)
- self.progress_signal.emit(f" Retrying in {wait_time} seconds...")
- time.sleep(wait_time)
- else:
- self.progress_signal.emit(f" ❌ All attempts failed for '{filename}'. Skipping.")
- grand_total_skip += 1
-
- self.overall_progress_signal.emit(total_files_in_chapter, i + 1)
- time.sleep(0.3)
-
- if self._check_pause(): break
-
- self.file_progress_signal.emit("", None)
- self.finished_signal.emit(grand_total_dl, grand_total_skip, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by AllComic thread.")
-
-class ToonilyDownloadThread(QThread):
- """A dedicated QThread for handling toonily.com series or single chapters."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool)
- overall_progress_signal = pyqtSignal(int, int) # Signal for chapter progress
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.start_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
- # Get access to the pause event from the main app
- self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
-
- def _check_pause(self):
- # Helper function to check for pause/cancel events
- if self.is_cancelled: return True
- if self.pause_event and self.pause_event.is_set():
- self.progress_signal.emit(" Download paused...")
- while self.pause_event.is_set():
- if self.is_cancelled: return True
- time.sleep(0.5)
- self.progress_signal.emit(" Download resumed.")
- return self.is_cancelled
-
- def run(self):
- grand_total_dl = 0
- grand_total_skip = 0
-
- # Check if the URL is a series or a chapter
- if '/chapter-' in self.start_url:
- # It's a single chapter URL
- chapters_to_download = [self.start_url]
- self.progress_signal.emit("ℹ️ Single Toonily chapter URL detected.")
- else:
- # It's a series URL, so get all chapters
- chapters_to_download = toonily_get_list(self.start_url, self.progress_signal.emit)
-
- if not chapters_to_download:
- self.progress_signal.emit("❌ No chapters found to download.")
- self.finished_signal.emit(0, 0, self.is_cancelled)
- return
-
- self.progress_signal.emit(f"--- Starting download of {len(chapters_to_download)} chapter(s) ---")
- self.overall_progress_signal.emit(len(chapters_to_download), 0)
-
- scraper = cloudscraper.create_scraper()
-
- for chapter_idx, chapter_url in enumerate(chapters_to_download):
- if self._check_pause(): break
-
- self.progress_signal.emit(f"\n-- Processing Chapter {chapter_idx + 1}/{len(chapters_to_download)} --")
- series_title, chapter_title, image_urls = toonily_fetch_data(chapter_url, self.progress_signal.emit, scraper)
-
- if not image_urls:
- self.progress_signal.emit(f"❌ Failed to get data for chapter. Skipping.")
- continue
-
- # Create folders like: /Downloads/Series Name/Chapter 01/
- series_folder_name = clean_folder_name(series_title)
- # Make a safe folder name from the full chapter title
- chapter_folder_name = clean_folder_name(chapter_title)
- final_save_path = os.path.join(self.output_dir, series_folder_name, chapter_folder_name)
-
- try:
- os.makedirs(final_save_path, exist_ok=True)
- self.progress_signal.emit(f" Saving to folder: '{os.path.join(series_folder_name, chapter_folder_name)}'")
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- grand_total_skip += len(image_urls)
- continue
-
- for i, img_url in enumerate(image_urls):
- if self._check_pause(): break
-
- try:
- file_extension = os.path.splitext(urlparse(img_url).path)[1] or '.jpg'
- filename = f"{i+1:03d}{file_extension}"
- filepath = os.path.join(final_save_path, filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({i+1}/{len(image_urls)}): '{filename}' already exists.")
- grand_total_skip += 1
- else:
- self.progress_signal.emit(f" Downloading ({i+1}/{len(image_urls)}): '{filename}'...")
- response = scraper.get(img_url, stream=True, timeout=60, headers={'Referer': chapter_url})
- response.raise_for_status()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self._check_pause(): break
- f.write(chunk)
-
- if self._check_pause():
- if os.path.exists(filepath): os.remove(filepath)
- break
-
- grand_total_dl += 1
- time.sleep(0.2)
- except Exception as e:
- self.progress_signal.emit(f" ❌ Failed to download '{filename}': {e}")
- grand_total_skip += 1
-
- self.overall_progress_signal.emit(len(chapters_to_download), chapter_idx + 1)
- time.sleep(1) # Wait a second between chapters
-
- self.file_progress_signal.emit("", None)
- self.finished_signal.emit(grand_total_dl, grand_total_skip, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Toonily thread.")
-
-
-
-class BunkrDownloadThread(QThread):
- """A dedicated QThread for handling Bunkr downloads."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool, list)
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.bunkr_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
-
- class ThreadLogger:
- def __init__(self, signal_emitter):
- self.signal_emitter = signal_emitter
- def info(self, msg, *args, **kwargs):
- self.signal_emitter.emit(str(msg))
- def error(self, msg, *args, **kwargs):
- self.signal_emitter.emit(f"❌ ERROR: {msg}")
- def warning(self, msg, *args, **kwargs):
- self.signal_emitter.emit(f"⚠️ WARNING: {msg}")
- def debug(self, msg, *args, **kwargs):
- pass
-
- self.logger = ThreadLogger(self.progress_signal)
-
- def run(self):
- download_count = 0
- skip_count = 0
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Bunkr Download for: {self.bunkr_url}")
-
- album_name, files_to_download = fetch_bunkr_data(self.bunkr_url, self.logger)
-
- if not files_to_download:
- self.progress_signal.emit("❌ Failed to extract file information from Bunkr. Aborting.")
- self.finished_signal.emit(0, 0, self.is_cancelled, [])
- return
-
- album_path = os.path.join(self.output_dir, album_name)
- try:
- os.makedirs(album_path, exist_ok=True)
- self.progress_signal.emit(f" Saving to folder: '{album_path}'")
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- self.finished_signal.emit(0, len(files_to_download), self.is_cancelled, [])
- return
-
- total_files = len(files_to_download)
- for i, file_data in enumerate(files_to_download):
- if self.is_cancelled:
- self.progress_signal.emit(" Download cancelled by user.")
- skip_count = total_files - download_count
- break
-
- filename = file_data.get('name', 'untitled_file')
- file_url = file_data.get('url')
- headers = file_data.get('_http_headers')
-
- filename = re.sub(r'[<>:"/\\|?*]', '_', filename).strip()
- filepath = os.path.join(album_path, filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
- skip_count += 1
- continue
-
- self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
-
- try:
- response = requests.get(file_url, stream=True, headers=headers, timeout=60)
- response.raise_for_status()
-
- total_size = int(response.headers.get('content-length', 0))
- downloaded_size = 0
- last_update_time = time.time()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled:
- break
- if chunk:
- f.write(chunk)
- downloaded_size += len(chunk)
- current_time = time.time()
- if total_size > 0 and (current_time - last_update_time) > 0.5:
- self.file_progress_signal.emit(filename, (downloaded_size, total_size))
- last_update_time = current_time
-
- if self.is_cancelled:
- if os.path.exists(filepath): os.remove(filepath)
- continue
-
- if total_size > 0:
- self.file_progress_signal.emit(filename, (total_size, total_size))
-
- download_count += 1
-
- except requests.exceptions.RequestException as e:
- self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
- except Exception as e:
- self.progress_signal.emit(f" ❌ An unexpected error occurred with '{filename}': {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
-
- self.file_progress_signal.emit("", None)
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled, [])
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Bunkr thread.")
-
-# In main_window.py, replace the old Hentai2readDownloadThread class with this one.
-
-class Hentai2readDownloadThread(QThread):
- """
- A dedicated QThread that calls the self-contained Hentai2Read client to
- perform scraping and downloading.
- """
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool)
- overall_progress_signal = pyqtSignal(int, int)
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.start_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
- self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
-
- def _check_pause(self):
- """Helper to handle pausing and cancellation events."""
- if self.is_cancelled: return True
- if self.pause_event and self.pause_event.is_set():
- self.progress_signal.emit(" Download paused...")
- while self.pause_event.is_set():
- if self.is_cancelled: return True
- time.sleep(0.5)
- self.progress_signal.emit(" Download resumed.")
- return self.is_cancelled
-
- def run(self):
- """
- Executes the main download logic by calling the dedicated client function.
- """
- downloaded, skipped = h2r_run_download(
- start_url=self.start_url,
- output_dir=self.output_dir,
- progress_callback=self.progress_signal.emit,
- overall_progress_callback=self.overall_progress_signal.emit,
- check_pause_func=self._check_pause
- )
-
- self.finished_signal.emit(downloaded, skipped, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
-
-class BooruDownloadThread(QThread):
- """A dedicated QThread for handling Danbooru and Gelbooru downloads."""
- progress_signal = pyqtSignal(str)
- overall_progress_signal = pyqtSignal(int, int)
- finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
-
- def __init__(self, url, output_dir, api_key, user_id, parent=None):
- super().__init__(parent)
- self.booru_url = url
- self.output_dir = output_dir
- self.api_key = api_key
- self.user_id = user_id
- self.is_cancelled = False
- self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
-
- def run(self):
- download_count = 0
- skip_count = 0
- processed_count = 0
- cumulative_total = 0
-
- def logger(msg):
- self.progress_signal.emit(str(msg))
-
- try:
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Booru Download for: {self.booru_url}")
-
- # This generator now yields both post data and ('PAGE_UPDATE', count) tuples
- item_generator = fetch_booru_data(self.booru_url, self.api_key, self.user_id, logger)
-
- download_path = self.output_dir # Default path
- path_initialized = False
-
- session = requests.Session()
- session.headers["User-Agent"] = USERAGENT_FIREFOX
-
- for item in item_generator:
- if self.is_cancelled:
- break
-
- # Check if this item is a page update message
- if isinstance(item, tuple) and item[0] == 'PAGE_UPDATE':
- newly_found = item[1]
- cumulative_total += newly_found
- self.progress_signal.emit(f" Found {newly_found} more posts. Total so far: {cumulative_total}")
- self.overall_progress_signal.emit(cumulative_total, processed_count)
- continue # Move to the next item from the generator
-
- # If it's not an update, it's a post dictionary
- post_data = item
- processed_count += 1
-
- # Initialize the download path with data from the first post found
- if not path_initialized:
- base_folder_name = post_data.get('search_tags', 'booru_download')
- download_path = os.path.join(self.output_dir, clean_folder_name(base_folder_name))
- os.makedirs(download_path, exist_ok=True)
- path_initialized = True
-
- if self.pause_event.is_set():
- self.progress_signal.emit(" Download paused...")
- while self.pause_event.is_set():
- if self.is_cancelled: break
- time.sleep(0.5)
- if self.is_cancelled: break
- self.progress_signal.emit(" Download resumed.")
-
- file_url = post_data.get('file_url')
- if not file_url:
- skip_count += 1
- self.progress_signal.emit(f" -> Skip ({processed_count}/{cumulative_total}): Post ID {post_data.get('id')} has no file URL.")
- continue
-
- cat = post_data.get('category', 'booru')
- post_id = post_data.get('id', 'unknown')
- md5 = post_data.get('md5', '')
- fname = post_data.get('filename', f"file_{post_id}")
- ext = post_data.get('extension', 'jpg')
-
- final_filename = f"{cat}_{post_id}_{md5 or fname}.{ext}"
- filepath = os.path.join(download_path, final_filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({processed_count}/{cumulative_total}): '{final_filename}' already exists.")
- skip_count += 1
- else:
- try:
- self.progress_signal.emit(f" Downloading ({processed_count}/{cumulative_total}): '{final_filename}'...")
- response = session.get(file_url, stream=True, timeout=60)
- response.raise_for_status()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled: break
- f.write(chunk)
-
- if not self.is_cancelled:
- download_count += 1
- else:
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
-
- except Exception as e:
- self.progress_signal.emit(f" ❌ Failed to download '{final_filename}': {e}")
- skip_count += 1
-
- self.overall_progress_signal.emit(cumulative_total, processed_count)
- time.sleep(0.2)
-
- if not path_initialized:
- self.progress_signal.emit("No posts found for the given URL/tags.")
-
- except BooruClientException as e:
- self.progress_signal.emit(f"❌ A Booru client error occurred: {e}")
- except Exception as e:
- self.progress_signal.emit(f"❌ An unexpected error occurred in Booru thread: {e}")
- finally:
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Booru thread.")
-
-class PixeldrainDownloadThread(QThread):
- """A dedicated QThread for handling pixeldrain.com downloads."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool) # dl_count, skip_count, cancelled
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.pixeldrain_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
-
- def run(self):
- download_count = 0
- skip_count = 0
- self.progress_signal.emit("=" * 40)
- self.progress_signal.emit(f"🚀 Starting Pixeldrain.com Download for: {self.pixeldrain_url}")
-
- album_title_raw, files_to_download = fetch_pixeldrain_data(self.pixeldrain_url, self.progress_signal.emit)
-
- if not files_to_download:
- self.progress_signal.emit("❌ Failed to extract file information from Pixeldrain. Aborting.")
- self.finished_signal.emit(0, 0, self.is_cancelled)
- return
-
- album_folder_name = clean_folder_name(album_title_raw)
- album_path = os.path.join(self.output_dir, album_folder_name)
- try:
- os.makedirs(album_path, exist_ok=True)
- self.progress_signal.emit(f" Saving to folder: '{album_path}'")
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- self.finished_signal.emit(0, len(files_to_download), self.is_cancelled)
- return
-
- total_files = len(files_to_download)
- session = cloudscraper.create_scraper()
-
- for i, file_data in enumerate(files_to_download):
- if self.is_cancelled:
- self.progress_signal.emit(" Download cancelled by user.")
- skip_count = total_files - download_count
- break
-
- filename = file_data.get('filename')
- file_url = file_data.get('url')
- filepath = os.path.join(album_path, filename)
-
- if os.path.exists(filepath):
- self.progress_signal.emit(f" -> Skip ({i+1}/{total_files}): '{filename}' already exists.")
- skip_count += 1
- continue
-
- self.progress_signal.emit(f" Downloading ({i+1}/{total_files}): '{filename}'...")
-
- try:
- response = session.get(file_url, stream=True, timeout=90)
- response.raise_for_status()
-
- total_size = int(response.headers.get('content-length', 0))
- downloaded_size = 0
- last_update_time = time.time()
-
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if self.is_cancelled:
- break
- if chunk:
- f.write(chunk)
- downloaded_size += len(chunk)
- current_time = time.time()
- if total_size > 0 and (current_time - last_update_time) > 0.5:
- self.file_progress_signal.emit(filename, (downloaded_size, total_size))
- last_update_time = current_time
-
- if self.is_cancelled:
- if os.path.exists(filepath): os.remove(filepath)
- continue
-
- download_count += 1
- except requests.exceptions.RequestException as e:
- self.progress_signal.emit(f" ❌ Failed to download '{filename}'. Error: {e}")
- if os.path.exists(filepath): os.remove(filepath)
- skip_count += 1
-
- self.file_progress_signal.emit("", None)
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- self.progress_signal.emit(" Cancellation signal received by Pixeldrain thread.")
-
-class MangaDexDownloadThread(QThread):
- """A wrapper QThread for running the MangaDex client function."""
- progress_signal = pyqtSignal(str)
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool)
- overall_progress_signal = pyqtSignal(int, int)
-
- def __init__(self, url, output_dir, parent=None):
- super().__init__(parent)
- self.start_url = url
- self.output_dir = output_dir
- self.is_cancelled = False
- self.pause_event = parent.pause_event if hasattr(parent, 'pause_event') else threading.Event()
- self.cancellation_event = parent.cancellation_event if hasattr(parent, 'cancellation_event') else threading.Event()
-
- def run(self):
- downloaded = 0
- skipped = 0
- try:
- downloaded, skipped = fetch_mangadex_data(
- self.start_url,
- self.output_dir,
- logger_func=self.progress_signal.emit,
- file_progress_callback=self.file_progress_signal,
- overall_progress_callback=self.overall_progress_signal,
- pause_event=self.pause_event,
- cancellation_event=self.cancellation_event
- )
- except Exception as e:
- self.progress_signal.emit(f"❌ A critical error occurred in the MangaDex thread: {e}")
- skipped = 1 # Mark as skipped if there was a critical failure
- finally:
- self.finished_signal.emit(downloaded, skipped, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
- if self.cancellation_event:
- self.cancellation_event.set()
- self.progress_signal.emit(" Cancellation signal received by MangaDex thread.")
-
-class DriveDownloadThread(QThread):
- """A dedicated QThread for handling direct Mega, GDrive, and Dropbox links."""
- # --- MODIFIED: Removed progress_signal, as we will use a direct logger ---
- file_progress_signal = pyqtSignal(str, object)
- finished_signal = pyqtSignal(int, int, bool, list)
- overall_progress_signal = pyqtSignal(int, int)
-
- # --- MODIFIED: Added logger_func to init ---
- def __init__(self, url, output_dir, platform, use_post_subfolder, cancellation_event, pause_event, logger_func, parent=None):
- super().__init__(parent)
- self.drive_url = url
- self.output_dir = output_dir
- self.platform = platform
- self.use_post_subfolder = use_post_subfolder
- self.is_cancelled = False
- self.cancellation_event = cancellation_event
- self.pause_event = pause_event
- self.logger_func = logger_func # Use the logger from the main window
-
- def run(self):
- # --- MODIFIED: Use self.logger_func directly ---
- self.logger_func("=" * 40)
- self.logger_func(f"🚀 Starting direct {self.platform.capitalize()} Download for: {self.drive_url}")
-
- try:
- if self.platform == 'mega':
- # Pass the logger function down
- drive_download_mega_file(
- self.drive_url, self.output_dir,
- logger_func=self.logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- overall_progress_callback=self.overall_progress_signal.emit,
- cancellation_event=self.cancellation_event,
- pause_event=self.pause_event
- )
- # (Other platforms like gdrive, dropbox, gofile remain the same, they will use the passed logger)
- elif self.platform == 'gdrive':
- download_gdrive_file(
- self.drive_url, self.output_dir,
- logger_func=self.logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- overall_progress_callback=self.overall_progress_signal.emit,
- use_post_subfolder=self.use_post_subfolder,
- post_title="Google Drive Download"
- )
- elif self.platform == 'dropbox':
- download_dropbox_file(
- self.drive_url, self.output_dir,
- logger_func=self.logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- use_post_subfolder=self.use_post_subfolder,
- post_title="Dropbox Download"
- )
- elif self.platform == 'gofile':
- download_gofile_folder(
- self.drive_url, self.output_dir,
- logger_func=self.logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- overall_progress_callback=self.overall_progress_signal.emit
- )
-
- self.finished_signal.emit(1, 0, self.is_cancelled, [])
-
- except Exception as e:
- self.logger_func(f"❌ An unexpected error occurred in DriveDownloadThread: {e}")
- self.finished_signal.emit(0, 1, self.is_cancelled, [])
-
- def cancel(self):
- self.is_cancelled = True
- if self.cancellation_event:
- self.cancellation_event.set()
- self.logger_func(f" Cancellation signal received by {self.platform.capitalize()} thread.")
-
-class ExternalLinkDownloadThread(QThread):
- """A QThread to handle downloading multiple external links sequentially."""
- progress_signal = pyqtSignal(str)
- file_complete_signal = pyqtSignal(str, bool)
- finished_signal = pyqtSignal()
- overall_progress_signal = pyqtSignal(int, int)
- file_progress_signal = pyqtSignal(str, object)
-
- def __init__(self, tasks_to_download, download_base_path, parent_logger_func, parent=None, use_post_subfolder=False):
- super().__init__(parent)
- self.tasks = tasks_to_download
- self.download_base_path = download_base_path
- self.parent_logger_func = parent_logger_func
- self.is_cancelled = False
- self.use_post_subfolder = use_post_subfolder
-
- def run(self):
- total_tasks = len(self.tasks)
- self.progress_signal.emit(f"ℹ️ Starting external link download thread for {total_tasks} link(s).")
- self.overall_progress_signal.emit(total_tasks, 0)
-
- for i, task_info in enumerate(self.tasks):
- if self.is_cancelled:
- self.progress_signal.emit("External link download cancelled by user.")
- break
-
- self.overall_progress_signal.emit(total_tasks, i + 1)
-
- platform = task_info.get('platform', 'unknown').lower()
- full_url = task_info['url']
- post_title = task_info['title']
-
- self.progress_signal.emit(f"Download ({i + 1}/{total_tasks}): Starting '{post_title}' ({platform.upper()}) from {full_url}")
-
- try:
- if platform == 'mega':
- drive_download_mega_file(
- full_url,
- self.download_base_path,
- logger_func=self.parent_logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- overall_progress_callback=self.overall_progress_signal.emit
- )
- elif platform == 'google drive':
- download_gdrive_file(
- full_url,
- self.download_base_path,
- logger_func=self.parent_logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- overall_progress_callback=self.overall_progress_signal.emit,
- use_post_subfolder=self.use_post_subfolder,
- post_title=post_title
- )
- # --- MODIFICATION: Pass new arguments to download_dropbox_file ---
- elif platform == 'dropbox':
- download_dropbox_file(
- full_url,
- self.download_base_path,
- logger_func=self.parent_logger_func,
- progress_callback_func=self.file_progress_signal.emit,
- use_post_subfolder=self.use_post_subfolder,
- post_title=post_title
- )
- # --- END MODIFICATION ---
- else:
- self.progress_signal.emit(f"⚠️ Unsupported platform '{platform}' for link: {full_url}")
- self.file_complete_signal.emit(full_url, False)
- continue
- self.file_complete_signal.emit(full_url, True)
- except Exception as e:
- self.progress_signal.emit(f"❌ Error downloading ({platform.upper()}) link '{full_url}': {e}")
- self.file_complete_signal.emit(full_url, False)
-
- self.finished_signal.emit()
-
- def cancel(self):
- """Sets the cancellation flag to stop the thread gracefully."""
- self.progress_signal.emit(" [External Links] Cancellation signal received by thread.")
- self.is_cancelled = True
-
-class NhentaiDownloadThread(QThread):
- progress_signal = pyqtSignal(str)
- finished_signal = pyqtSignal(int, int, bool)
-
- IMAGE_SERVERS = [
- "https://i.nhentai.net", "https://i2.nhentai.net", "https://i3.nhentai.net",
- "https://i5.nhentai.net", "https://i7.nhentai.net"
- ]
-
- EXTENSION_MAP = {'j': 'jpg', 'p': 'png', 'g': 'gif', 'w': 'webp' }
-
- def __init__(self, gallery_data, output_dir, parent=None):
- super().__init__(parent)
- self.gallery_data = gallery_data
- self.output_dir = output_dir
- self.is_cancelled = False
-
- def run(self):
- title = self.gallery_data.get("title", {}).get("english", f"gallery_{self.gallery_data.get('id')}")
- gallery_id = self.gallery_data.get("id")
- media_id = self.gallery_data.get("media_id")
- pages_info = self.gallery_data.get("pages", [])
-
- folder_name = clean_folder_name(title)
- gallery_path = os.path.join(self.output_dir, folder_name)
-
- try:
- os.makedirs(gallery_path, exist_ok=True)
- except OSError as e:
- self.progress_signal.emit(f"❌ Critical error creating directory: {e}")
- self.finished_signal.emit(0, len(pages_info), False)
- return
-
- self.progress_signal.emit(f"⬇️ Downloading '{title}' to folder '{folder_name}'...")
-
- # Create a single cloudscraper instance for the entire download
- scraper = cloudscraper.create_scraper()
- download_count = 0
- skip_count = 0
-
- for i, page_data in enumerate(pages_info):
- if self.is_cancelled:
- break
-
- page_num = i + 1
-
- ext_char = page_data.get('t', 'j')
- 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):
- self.progress_signal.emit(f" -> Skip (Exists): {local_filename}")
- skip_count += 1
- continue
-
- download_successful = False
- for server in self.IMAGE_SERVERS:
- if self.is_cancelled:
- break
-
- full_url = f"{server}{relative_path}"
- try:
- self.progress_signal.emit(f" Downloading page {page_num}/{len(pages_info)} from {server} ...")
-
- 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',
- 'Referer': f'https://nhentai.net/g/{gallery_id}/'
- }
-
- # Use the scraper instance to get the image
- response = scraper.get(full_url, headers=headers, timeout=60, stream=True)
-
- if response.status_code == 200:
- with open(filepath, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- f.write(chunk)
- download_count += 1
- download_successful = True
- break
- else:
- self.progress_signal.emit(f" -> {server} returned status {response.status_code}. Trying next server...")
-
- except Exception as e:
- self.progress_signal.emit(f" -> {server} failed to connect or timed out: {e}. Trying next server...")
-
- if not download_successful:
- self.progress_signal.emit(f" ❌ Failed to download {local_filename} from all servers.")
- skip_count += 1
-
- time.sleep(0.5)
-
- self.finished_signal.emit(download_count, skip_count, self.is_cancelled)
-
- def cancel(self):
- self.is_cancelled = True
+ QTimer.singleShot(100, self._process_next_favorite_download)
\ No newline at end of file
diff --git a/src/utils/network_utils.py b/src/utils/network_utils.py
index 63e1484..86c5a1c 100644
--- a/src/utils/network_utils.py
+++ b/src/utils/network_utils.py
@@ -137,6 +137,12 @@ def extract_post_info(url_string):
stripped_url = url_string.strip()
+ # --- Rule34Video Check ---
+ rule34video_match = re.search(r'rule34video\.com/video/(\d+)', stripped_url)
+ if rule34video_match:
+ video_id = rule34video_match.group(1)
+ return 'rule34video', video_id, None
+
# --- Danbooru Check ---
danbooru_match = re.search(r'danbooru\.donmai\.us|safebooru\.donmai\.us', stripped_url)
if danbooru_match: