5 Commits

Author SHA1 Message Date
Yuvi9587
6a76ae8a55 readme.md 2025-05-23 18:40:11 +05:30
Yuvi9587
ec9862d7ab readme.md 2025-05-23 18:39:11 +05:30
Yuvi9587
a42d4dec79 readme.md 2025-05-23 18:37:38 +05:30
Yuvi9587
1bddd8399a readme.md 2025-05-23 18:36:25 +05:30
Yuvi9587
a88edd89de readme.md 2025-05-23 18:26:11 +05:30
7 changed files with 108 additions and 708 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

1
Known.txt Normal file
View File

@@ -0,0 +1 @@
([Yor], Yor Briar, Yor Forger)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -31,7 +31,6 @@ from io import BytesIO
STYLE_POST_TITLE = "post_title" STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name" STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based" # For manga date-based sequential naming STYLE_DATE_BASED = "date_based" # For manga date-based sequential naming
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # For manga post title + global counter
SKIP_SCOPE_FILES = "files" SKIP_SCOPE_FILES = "files"
SKIP_SCOPE_POSTS = "posts" SKIP_SCOPE_POSTS = "posts"
@@ -277,7 +276,7 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
return None return None
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None): def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
if cancellation_event and cancellation_event.is_set(): # type: ignore if cancellation_event and cancellation_event.is_set():
logger(" Fetch cancelled before request.") logger(" Fetch cancelled before request.")
raise RuntimeError("Fetch operation cancelled by user.") raise RuntimeError("Fetch operation cancelled by user.")
@@ -285,7 +284,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(" Post fetching paused...") logger(" Post fetching paused...")
while pause_event.is_set(): while pause_event.is_set():
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
logger(" Post fetching cancelled while paused.") # type: ignore logger(" Post fetching cancelled while paused.")
raise RuntimeError("Fetch operation cancelled by user.") raise RuntimeError("Fetch operation cancelled by user.")
time.sleep(0.5) time.sleep(0.5)
logger(" Post fetching resumed.") logger(" Post fetching resumed.")
@@ -380,37 +379,21 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
page_size = 50 page_size = 50
if is_creator_feed_for_manga: if is_creator_feed_for_manga:
logger(" Manga Mode: Fetching posts to sort by date (oldest processed first)...") logger(" Manga Mode: Fetching all posts to reverse order (oldest posts processed first)...")
all_posts_for_manga_mode = [] all_posts_for_manga_mode = []
current_offset_manga = 0 current_offset_manga = 0
# Determine starting page and offset for manga mode
if start_page and start_page > 1:
current_offset_manga = (start_page - 1) * page_size
logger(f" Manga Mode: Starting fetch from page {start_page} (offset {current_offset_manga}).")
elif start_page: # start_page is 1
logger(f" Manga Mode: Starting fetch from page 1 (offset 0).")
if end_page:
logger(f" Manga Mode: Will fetch up to page {end_page}.")
while True: while True:
if pause_event and pause_event.is_set(): if pause_event and pause_event.is_set():
logger(" Manga mode post fetching paused...") # type: ignore logger(" Manga mode post fetching paused...") # type: ignor
while pause_event.is_set(): while pause_event.is_set():
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled while paused.") # type: ignore logger(" Manga mode post fetching cancelled while paused.")
break break
time.sleep(0.5) time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.") if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
if cancellation_event and cancellation_event.is_set(): if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled.") logger(" Manga mode post fetching cancelled.")
break break
current_page_num_manga = (current_offset_manga // page_size) + 1
if end_page and current_page_num_manga > end_page:
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break
try: try:
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance(posts_batch_manga, list): if not isinstance(posts_batch_manga, list):
@@ -418,11 +401,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
break break
if not posts_batch_manga: if not posts_batch_manga:
logger("✅ Reached end of posts (Manga Mode fetch all).") logger("✅ Reached end of posts (Manga Mode fetch all).")
if start_page and not end_page and current_page_num_manga < start_page: # Started on a page with no posts break
logger(f" Manga Mode: No posts found on or after specified start page {start_page}.")
elif end_page and current_page_num_manga <= end_page and not all_posts_for_manga_mode: # Range specified but no posts in it
logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
break # No more posts from API
all_posts_for_manga_mode.extend(posts_batch_manga) all_posts_for_manga_mode.extend(posts_batch_manga)
current_offset_manga += page_size # Increment by page_size for the next API call's 'o' parameter current_offset_manga += page_size # Increment by page_size for the next API call's 'o' parameter
time.sleep(0.6) time.sleep(0.6)
@@ -441,7 +420,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
if all_posts_for_manga_mode: if all_posts_for_manga_mode:
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...") logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
# ... (rest of sorting and yielding logic for manga mode remains the same) ...
def sort_key_tuple(post): def sort_key_tuple(post):
published_date_str = post.get('published') published_date_str = post.get('published')
added_date_str = post.get('added') added_date_str = post.get('added')
@@ -605,8 +584,7 @@ class PostProcessorWorker:
selected_cookie_file=None, # Added missing parameter selected_cookie_file=None, # Added missing parameter
app_base_dir=None, # New parameter for app's base directory app_base_dir=None, # New parameter for app's base directory
manga_date_file_counter_ref=None, # New parameter for date-based manga naming manga_date_file_counter_ref=None, # New parameter for date-based manga naming
manga_global_file_counter_ref=None, # New parameter for global numbering ):
): # type: ignore
self.post = post_data self.post = post_data
self.download_root = download_root self.download_root = download_root
self.known_names = known_names self.known_names = known_names
@@ -652,7 +630,6 @@ class PostProcessorWorker:
self.selected_cookie_file = selected_cookie_file # Store selected cookie file path self.selected_cookie_file = selected_cookie_file # Store selected cookie file path
self.app_base_dir = app_base_dir # Store app base dir self.app_base_dir = app_base_dir # Store app base dir
self.cookie_text = cookie_text # Store cookie text self.cookie_text = cookie_text # Store cookie text
self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store global counter
self.use_cookie = use_cookie # Store cookie setting self.use_cookie = use_cookie # Store cookie setting
if self.compress_images and Image is None: if self.compress_images and Image is None:
@@ -690,7 +667,6 @@ class PostProcessorWorker:
post_title="", file_index_in_post=0, num_files_in_this_post=1, post_title="", file_index_in_post=0, num_files_in_this_post=1,
manga_date_file_counter_ref=None): # Added manga_date_file_counter_ref manga_date_file_counter_ref=None): # Added manga_date_file_counter_ref
was_original_name_kept_flag = False was_original_name_kept_flag = False
manga_global_file_counter_ref = None # Placeholder, will be passed from process()
final_filename_saved_for_return = "" final_filename_saved_for_return = ""
def _get_current_character_filters(self): def _get_current_character_filters(self):
@@ -701,8 +677,7 @@ class PostProcessorWorker:
def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event, def _download_single_file(self, file_info, target_folder_path, headers, original_post_id_for_log, skip_event,
post_title="", file_index_in_post=0, num_files_in_this_post=1, # Added manga_date_file_counter_ref post_title="", file_index_in_post=0, num_files_in_this_post=1, # Added manga_date_file_counter_ref
manga_date_file_counter_ref=None, manga_date_file_counter_ref=None,
forced_filename_override=None, # New for retries forced_filename_override=None): # New for retries
manga_global_file_counter_ref=None): # New for global numbering
was_original_name_kept_flag = False was_original_name_kept_flag = False
final_filename_saved_for_return = "" final_filename_saved_for_return = ""
retry_later_details = None # For storing info if retryable failure retry_later_details = None # For storing info if retryable failure
@@ -764,19 +739,6 @@ class PostProcessorWorker:
self.logger(f"⚠️ Manga Date Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_date_file_counter_ref}") self.logger(f"⚠️ Manga Date Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_date_file_counter_ref}")
filename_to_save_in_main_path = clean_filename(api_original_filename) filename_to_save_in_main_path = clean_filename(api_original_filename)
self.logger(f"⚠️ Manga mode (Date Based Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path}' for post {original_post_id_for_log}.") self.logger(f"⚠️ Manga mode (Date Based Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path}' for post {original_post_id_for_log}.")
elif self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING:
if manga_global_file_counter_ref is not None and len(manga_global_file_counter_ref) == 2:
counter_val_for_filename = -1
counter_lock = manga_global_file_counter_ref[1]
with counter_lock:
counter_val_for_filename = manga_global_file_counter_ref[0]
manga_global_file_counter_ref[0] += 1
cleaned_post_title_base_for_global = clean_filename(post_title.strip() if post_title and post_title.strip() else "post")
filename_to_save_in_main_path = f"{cleaned_post_title_base_for_global}_{counter_val_for_filename:03d}{original_ext}"
else:
self.logger(f"⚠️ Manga Title+GlobalNum Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_global_file_counter_ref}")
self.logger(f"⚠️ Manga mode (Date Based Style Fallback): Using cleaned original filename '{filename_to_save_in_main_path}' for post {original_post_id_for_log}.")
else: else:
self.logger(f"⚠️ Manga mode: Unknown filename style '{self.manga_filename_style}'. Defaulting to original filename for '{api_original_filename}'.") self.logger(f"⚠️ Manga mode: Unknown filename style '{self.manga_filename_style}'. Defaulting to original filename for '{api_original_filename}'.")
filename_to_save_in_main_path = clean_filename(api_original_filename) filename_to_save_in_main_path = clean_filename(api_original_filename)
@@ -1467,14 +1429,6 @@ class PostProcessorWorker:
target_folder_path_for_this_file = current_path_for_file target_folder_path_for_this_file = current_path_for_file
manga_date_counter_to_pass = None
manga_global_counter_to_pass = None
if self.manga_mode_active:
if self.manga_filename_style == STYLE_DATE_BASED:
manga_date_counter_to_pass = self.manga_date_file_counter_ref
elif self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING:
manga_global_counter_to_pass = self.manga_global_file_counter_ref if self.manga_global_file_counter_ref is not None else self.manga_date_file_counter_ref
futures_list.append(file_pool.submit( futures_list.append(file_pool.submit(
self._download_single_file, self._download_single_file,
file_info_to_dl, file_info_to_dl,
@@ -1482,9 +1436,8 @@ class PostProcessorWorker:
headers, headers,
post_id, post_id,
self.skip_current_file_flag, self.skip_current_file_flag,
post_title=post_title, post_title=post_title, # Keyword argument
manga_date_file_counter_ref=manga_date_counter_to_pass, manga_date_file_counter_ref=self.manga_date_file_counter_ref if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED else None,
manga_global_file_counter_ref=manga_global_counter_to_pass,
file_index_in_post=file_idx, # Changed to keyword argument file_index_in_post=file_idx, # Changed to keyword argument
num_files_in_this_post=num_files_in_this_post_for_naming # Changed to keyword argument num_files_in_this_post=num_files_in_this_post_for_naming # Changed to keyword argument
)) ))
@@ -1552,9 +1505,6 @@ class DownloadThread(QThread):
selected_cookie_file=None, # New parameter for selected cookie file selected_cookie_file=None, # New parameter for selected cookie file
app_base_dir=None, # New parameter app_base_dir=None, # New parameter
manga_date_file_counter_ref=None, # New parameter manga_date_file_counter_ref=None, # New parameter
manga_global_file_counter_ref=None, # New parameter for global numbering
use_cookie=False, # Added: Expected by main.py
cookie_text="", # Added: Expected by main.py
): ):
super().__init__() super().__init__()
self.api_url_input = api_url_input self.api_url_input = api_url_input
@@ -1603,7 +1553,6 @@ class DownloadThread(QThread):
self.cookie_text = cookie_text # Store cookie text self.cookie_text = cookie_text # Store cookie text
self.use_cookie = use_cookie # Store cookie setting self.use_cookie = use_cookie # Store cookie setting
self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread
self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store for global numbering
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
self.compress_images = False self.compress_images = False
@@ -1642,8 +1591,8 @@ class DownloadThread(QThread):
not self.extract_links_only and current_manga_date_file_counter_ref is None: # Check if it needs calculation not self.extract_links_only and current_manga_date_file_counter_ref is None: # Check if it needs calculation
series_scan_dir = self.output_dir series_scan_dir = self.output_dir
if self.use_subfolders: if self.use_subfolders:
if self.filter_character_list_objects_initial and self.filter_character_list_objects_initial[0] and self.filter_character_list_objects_initial[0].get("name"): if self.filter_character_list_objects and self.filter_character_list_objects[0] and self.filter_character_list_objects[0].get("name"):
series_folder_name = clean_folder_name(self.filter_character_list_objects_initial[0]["name"]) series_folder_name = clean_folder_name(self.filter_character_list_objects[0]["name"])
series_scan_dir = os.path.join(series_scan_dir, series_folder_name) series_scan_dir = os.path.join(series_scan_dir, series_folder_name)
elif self.service and self.user_id: elif self.service and self.user_id:
creator_based_folder_name = clean_folder_name(self.user_id) creator_based_folder_name = clean_folder_name(self.user_id)
@@ -1656,16 +1605,9 @@ class DownloadThread(QThread):
for filename_to_check in filenames_in_dir: for filename_to_check in filenames_in_dir:
base_name_no_ext = os.path.splitext(filename_to_check)[0] base_name_no_ext = os.path.splitext(filename_to_check)[0]
match = re.match(r"(\d{3,})", base_name_no_ext) match = re.match(r"(\d{3,})", base_name_no_ext)
if match: highest_num = max(highest_num, int(match.group(1))) # Corrected indentation if match: highest_num = max(highest_num, int(match.group(1)))
current_manga_date_file_counter_ref = [highest_num + 1, threading.Lock()] current_manga_date_file_counter_ref = [highest_num + 1, threading.Lock()]
self.logger(f" [Thread] Manga Date Mode: Initialized counter at {current_manga_date_file_counter_ref[0]}.") self.logger(f" [Thread] Manga Date Mode: Initialized counter at {current_manga_date_file_counter_ref[0]}.")
elif self.manga_mode_active and self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING and not self.extract_links_only and current_manga_date_file_counter_ref is None: # Use current_manga_date_file_counter_ref for STYLE_POST_TITLE_GLOBAL_NUMBERING as well
# For global numbering, we always start from 1 for the session unless a ref is passed.
# If you need to resume global numbering across sessions, similar scanning logic would be needed.
# For now, it starts at 1 per session if no ref is provided.
current_manga_date_file_counter_ref = [1, threading.Lock()] # Start global numbering at 1
self.logger(f" [Thread] Manga Title+GlobalNum Mode: Initialized counter at {current_manga_date_file_counter_ref[0]}.")
worker_signals_obj = PostProcessorSignals() worker_signals_obj = PostProcessorSignals()
try: try:
worker_signals_obj.progress_signal.connect(self.progress_signal) worker_signals_obj.progress_signal.connect(self.progress_signal)
@@ -1732,7 +1674,6 @@ class DownloadThread(QThread):
selected_cookie_file=self.selected_cookie_file, # Pass selected cookie file selected_cookie_file=self.selected_cookie_file, # Pass selected cookie file
app_base_dir=self.app_base_dir, # Pass app_base_dir app_base_dir=self.app_base_dir, # Pass app_base_dir
cookie_text=self.cookie_text, # Pass cookie text cookie_text=self.cookie_text, # Pass cookie text
manga_global_file_counter_ref=self.manga_global_file_counter_ref, # Pass the ref
use_cookie=self.use_cookie, # Pass cookie setting to worker use_cookie=self.use_cookie, # Pass cookie setting to worker
manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref
) )

694
main.py
View File

@@ -16,8 +16,7 @@ from concurrent.futures import ThreadPoolExecutor, CancelledError, Future
from PyQt5.QtGui import ( from PyQt5.QtGui import (
QIcon, QIcon,
QIntValidator, QIntValidator
QDesktopServices
) )
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
@@ -27,7 +26,7 @@ from PyQt5.QtWidgets import (
QFrame, QFrame,
QAbstractButton QAbstractButton
) )
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer, QSettings, QStandardPaths, QCoreApplication, QUrl, QSize from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer, QSettings, QStandardPaths, QCoreApplication
from urllib.parse import urlparse from urllib.parse import urlparse
try: try:
@@ -54,33 +53,20 @@ try:
CHAR_SCOPE_FILES, # Ensure this is imported CHAR_SCOPE_FILES, # Ensure this is imported
CHAR_SCOPE_BOTH, CHAR_SCOPE_BOTH,
CHAR_SCOPE_COMMENTS, CHAR_SCOPE_COMMENTS,
FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, # Import the new status FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER # Import the new status
STYLE_POST_TITLE_GLOBAL_NUMBERING # Import new manga style
) )
print("Successfully imported names from downloader_utils.") print("Successfully imported names from downloader_utils.")
except ImportError as e: except ImportError as e:
print(f"--- IMPORT ERROR ---") print(f"--- IMPORT ERROR ---")
print(f"Failed to import from 'downloader_utils.py': {e}") print(f"Failed to import from 'downloader_utils.py': {e}")
print(f"--- Check downloader_utils.py for syntax errors or missing dependencies. ---")
KNOWN_NAMES = [] KNOWN_NAMES = []
PostProcessorSignals = QObject
PostProcessorWorker = object PostProcessorWorker = object
# Create a mock PostProcessorSignals class with the expected signals
class _MockPostProcessorSignals(QObject):
progress_signal = pyqtSignal(str)
file_download_status_signal = pyqtSignal(bool)
external_link_signal = pyqtSignal(str, str, str, str)
file_progress_signal = pyqtSignal(str, object)
missed_character_post_signal = pyqtSignal(str, str)
# Add any other signals that might be expected if the real class is extended
def __init__(self, parent=None):
super().__init__(parent)
print("WARNING: Using MOCK PostProcessorSignals due to import error from downloader_utils.py. Some functionalities might be impaired.")
PostProcessorSignals = _MockPostProcessorSignals # Use the mock class
BackendDownloadThread = QThread BackendDownloadThread = QThread
def clean_folder_name(n): return str(n) def clean_folder_name(n): return str(n)
def extract_post_info(u): return None, None, None def extract_post_info(u): return None, None, None
def download_from_api(*a, **k): yield [] def download_from_api(*a, **k): yield []
SKIP_SCOPE_FILES = "files" # type: ignore SKIP_SCOPE_FILES = "files"
SKIP_SCOPE_POSTS = "posts" SKIP_SCOPE_POSTS = "posts"
SKIP_SCOPE_BOTH = "both" SKIP_SCOPE_BOTH = "both"
CHAR_SCOPE_TITLE = "title" CHAR_SCOPE_TITLE = "title"
@@ -88,7 +74,6 @@ except ImportError as e:
CHAR_SCOPE_BOTH = "both" CHAR_SCOPE_BOTH = "both"
CHAR_SCOPE_COMMENTS = "comments" CHAR_SCOPE_COMMENTS = "comments"
FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later" FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later"
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # Mock for safety
except Exception as e: except Exception as e:
print(f"--- UNEXPECTED IMPORT ERROR ---") print(f"--- UNEXPECTED IMPORT ERROR ---")
@@ -115,7 +100,6 @@ MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
STYLE_POST_TITLE = "post_title" STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name" STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based" # New style for date-based naming STYLE_DATE_BASED = "date_based" # New style for date-based naming
STYLE_POST_TITLE_GLOBAL_NUMBERING = STYLE_POST_TITLE_GLOBAL_NUMBERING # Use imported or mocked
SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1" SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1"
ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1" ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
@@ -123,6 +107,7 @@ USE_COOKIE_KEY = "useCookieV1" # New setting key
COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text
CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1" CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1"
# Custom dialog result constants for ConfirmAddAllDialog
CONFIRM_ADD_ALL_ACCEPTED = 1 CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -229,148 +214,9 @@ class ConfirmAddAllDialog(QDialog):
super().exec_() super().exec_()
# If user accepted but selected nothing, treat it as skipping addition # If user accepted but selected nothing, treat it as skipping addition
if isinstance(self.user_choice, list) and not self.user_choice: if isinstance(self.user_choice, list) and not self.user_choice:
# QMessageBox.information(self, "No Selection", "No names were selected to be added. Skipping addition.") QMessageBox.information(self, "No Selection", "No names were selected to be added. Skipping addition.")
return CONFIRM_ADD_ALL_SKIP_ADDING return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice return self.user_choice
class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide."""
def __init__(self, steps_data, parent=None):
super().__init__(parent)
self.current_step = 0
self.steps_data = steps_data # List of (title, content_html) tuples
self.setWindowTitle("Kemono Downloader - Feature Guide")
self.setModal(True)
self.setFixedSize(650, 600) # Adjusted size for guide content
# Apply similar styling to TourDialog, or a distinct one if preferred
self.setStyleSheet(parent.get_dark_theme() if hasattr(parent, 'get_dark_theme') else """
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
QLabel { color: #E0E0E0; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
QPushButton:hover { background-color: #656565; }
QPushButton:pressed { background-color: #4A4A4A; }
""")
self._init_ui()
if parent: # Attempt to center on parent
self.move(parent.geometry().center() - self.rect().center())
def _init_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1)
self.tour_steps_widgets = [] # To hold TourStepWidget instances
for title, content in self.steps_data:
step_widget = TourStepWidget(title, content) # Reuse TourStepWidget
self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
buttons_layout = QHBoxLayout()
buttons_layout.setContentsMargins(15, 10, 15, 15)
buttons_layout.setSpacing(10)
self.back_button = QPushButton("Back")
self.back_button.clicked.connect(self._previous_step)
self.back_button.setEnabled(False)
# Determine base directory for assets
# This logic assumes 'assest' folder is at the same level as main.py or the executable
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# For PyInstaller, assets are in _MEIPASS or a relative path from executable
# If 'assest' is bundled at the root of _MEIPASS:
assets_base_dir = sys._MEIPASS
# If 'assest' is bundled relative to the executable directory:
# assets_base_dir = os.path.dirname(sys.executable)
else:
# For development, assets are relative to the script
assets_base_dir = os.path.dirname(os.path.abspath(__file__))
github_icon_path = os.path.join(assets_base_dir, "assets", "github.png")
instagram_icon_path = os.path.join(assets_base_dir, "assets", "instagram.png")
discord_icon_path = os.path.join(assets_base_dir, "assets", "discord.png")
self.github_button = QPushButton(QIcon(github_icon_path), "")
self.instagram_button = QPushButton(QIcon(instagram_icon_path), "")
self.Discord_button = QPushButton(QIcon(discord_icon_path), "")
# Optional: Set a fixed icon size for consistency
icon_size = QSize(24, 24) # Adjust as needed
self.github_button.setIconSize(icon_size)
self.instagram_button.setIconSize(icon_size)
self.Discord_button.setIconSize(icon_size)
self.next_button = QPushButton("Next")
self.next_button.clicked.connect(self._next_step_action)
self.next_button.setDefault(True)
self.github_button.clicked.connect(self._open_github_link)
self.instagram_button.clicked.connect(self._open_instagram_link)
self.Discord_button.clicked.connect(self._open_Discord_link)
self.github_button.setToolTip("Visit project's GitHub page (Opens in browser)")
self.instagram_button.setToolTip("Visit our Instagram page (Opens in browser)")
self.Discord_button.setToolTip("Visit our Discord community (Opens in browser)")
# Social media buttons layout
social_layout = QHBoxLayout()
social_layout.setSpacing(10)
social_layout.addWidget(self.github_button)
social_layout.addWidget(self.instagram_button)
social_layout.addWidget(self.Discord_button)
# social_layout.addStretch(1) # Pushes social buttons to the left if uncommented and placed before nav buttons
# Add social buttons to the main buttons_layout, before the stretch, to keep them left
# Clear buttons_layout and rebuild to ensure order
while buttons_layout.count():
item = buttons_layout.takeAt(0) # Removes the item from the layout
if item.widget(): # Check if the item is a widget
item.widget().setParent(None) # Detach the widget from this layout
elif item.layout(): # If it's a sub-layout
pass # Sub-layouts are handled by Qt's ownership or need explicit deletion if complex
buttons_layout.addLayout(social_layout) # Add social buttons on the left
buttons_layout.addStretch(1) # Stretch between social and nav buttons
buttons_layout.addWidget(self.back_button) # Back and Next on the right
buttons_layout.addWidget(self.next_button)
main_layout.addLayout(buttons_layout)
self._update_button_states() # Set initial button states
def _next_step_action(self):
if self.current_step < len(self.tour_steps_widgets) - 1:
self.current_step += 1
self.stacked_widget.setCurrentIndex(self.current_step)
else: # Last page
self.accept() # Close dialog
self._update_button_states()
def _previous_step(self):
if self.current_step > 0:
self.current_step -= 1
self.stacked_widget.setCurrentIndex(self.current_step)
self._update_button_states()
def _update_button_states(self):
if self.current_step == len(self.tour_steps_widgets) - 1:
self.next_button.setText("Finish")
else:
self.next_button.setText("Next")
self.back_button.setEnabled(self.current_step > 0)
def _open_github_link(self):
# Replace with your actual GitHub project URL
QDesktopServices.openUrl(QUrl("https://github.com/Yuvi9587"))
def _open_instagram_link(self):
# Replace with your actual Instagram URL
QDesktopServices.openUrl(QUrl("https://www.instagram.com/uvi.arts/"))
def _open_Discord_link(self):
# Replace with your actual Discord URL
QDesktopServices.openUrl(QUrl("https://discord.gg/BqP64XTdJN"))
class TourStepWidget(QWidget): class TourStepWidget(QWidget):
"""A single step/page in the tour.""" """A single step/page in the tour."""
def __init__(self, title_text, content_text, parent=None): def __init__(self, title_text, content_text, parent=None):
@@ -857,8 +703,9 @@ class DownloaderApp(QWidget):
print(f" Known.txt will be loaded/saved at: {self.config_file}") print(f" Known.txt will be loaded/saved at: {self.config_file}")
self.setWindowTitle("Kemono Downloader v4.0.0")
# self.load_known_names_from_util() # This call is premature and causes the error. self.load_known_names_from_util()
self.setWindowTitle("Kemono Downloader v3.5.0")
self.setStyleSheet(self.get_dark_theme()) self.setStyleSheet(self.get_dark_theme())
self.init_ui() self.init_ui()
@@ -878,8 +725,8 @@ class DownloaderApp(QWidget):
def _get_tooltip_for_character_input(self): def _get_tooltip_for_character_input(self):
return ( return (
"Names, comma-separated.\n" "Names, comma-separated.\n"
"- Individual names: `Tifa`, `Aerith` (separate folders, separate Known.txt entries).\n" "- Individual names: `Tifa`, `Aerith`\n"
"- Group for shared folder, separate Known.txt: `(Vivi, Ulti, Uta)` -> creates folder 'Vivi Ulti Uta', but adds Vivi, Ulti, Uta as separate Known.txt entries.\n" "- Group for separate folders: `(Vivi, Ulti, Uta)` -> creates separate Known.txt entries & folders for Vivi, Ulti, Uta.\n"
"- Group for a single shared folder: `(Yuffie, Sonon)~` (note the `~`) -> creates one Known.txt entry for folder 'Yuffie Sonon', with Yuffie and Sonon as aliases.\n" "- Group for a single shared folder: `(Yuffie, Sonon)~` (note the `~`) -> creates one Known.txt entry for folder 'Yuffie Sonon', with Yuffie and Sonon as aliases.\n"
"All names in any group type are used as aliases for matching content." "All names in any group type are used as aliases for matching content."
) )
@@ -991,18 +838,11 @@ class DownloaderApp(QWidget):
group_content_str = part_str[1:-1].strip() group_content_str = part_str[1:-1].strip()
aliases_in_group = [alias.strip() for alias in group_content_str.split(',') if alias.strip()] aliases_in_group = [alias.strip() for alias in group_content_str.split(',') if alias.strip()]
if aliases_in_group: if aliases_in_group:
# For (A, B, C) type groups: # Create separate entries for each item in a non-tilde group
# Create a single filter object for a shared folder in the current download. for alias_item in aliases_in_group:
# Mark with a special flag to handle Known.txt addition differently. parsed_character_filter_objects.append({"name": alias_item, "is_group": False, "aliases": [alias_item]})
group_folder_name = " ".join(aliases_in_group) # Folder name from all aliases
parsed_character_filter_objects.append({
"name": group_folder_name,
"is_group": True, # Behaves like a tilde group for current download folder
"aliases": aliases_in_group,
"components_are_distinct_for_known_txt": True # New flag
})
else: else:
parsed_character_filter_objects.append({"name": part_str, "is_group": False, "aliases": [part_str], "components_are_distinct_for_known_txt": False}) # Standard single entry parsed_character_filter_objects.append({"name": part_str, "is_group": False, "aliases": [part_str]}) # Standard single entry
return parsed_character_filter_objects return parsed_character_filter_objects
def _process_worker_queue(self): def _process_worker_queue(self):
@@ -1523,18 +1363,8 @@ class DownloaderApp(QWidget):
self.new_char_input.returnPressed.connect(self.add_char_button.click) self.new_char_input.returnPressed.connect(self.add_char_button.click)
self.delete_char_button.clicked.connect(self.delete_selected_character) self.delete_char_button.clicked.connect(self.delete_selected_character)
char_manage_layout.addWidget(self.new_char_input, 2) char_manage_layout.addWidget(self.new_char_input, 2)
char_manage_layout.addWidget(self.add_char_button, 0) char_manage_layout.addWidget(self.add_char_button, 1)
char_manage_layout.addWidget(self.delete_char_button, 1)
# Help button for Known Names list
self.known_names_help_button = QPushButton("?") # Restored question mark
self.known_names_help_button.setFixedWidth(35) # Small width for a square-like button
# self.known_names_help_button.setStyleSheet("font-weight: bold; padding-left: 8px; padding-right: 8px;") # Removed stylesheet
self.known_names_help_button.setToolTip("Open the application feature guide.")
self.known_names_help_button.clicked.connect(self._show_feature_guide)
char_manage_layout.addWidget(self.delete_char_button, 0)
char_manage_layout.addWidget(self.known_names_help_button, 0) # Moved to the end (rightmost)
left_layout.addLayout(char_manage_layout) left_layout.addLayout(char_manage_layout)
left_layout.addStretch(0) left_layout.addStretch(0)
@@ -2062,17 +1892,10 @@ class DownloaderApp(QWidget):
self.log_signal.emit(f"="*20 + f" Mode changed to: {filter_mode_text} " + "="*20) self.log_signal.emit(f"="*20 + f" Mode changed to: {filter_mode_text} " + "="*20)
subfolders_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False subfolders_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
manga_on = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False manga_on = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
# Determine if character filter section should be active (visible and enabled) enable_character_filter_related_widgets = file_download_mode_active and (subfolders_on or manga_on)
# It should be active if we are in a file downloading mode (not 'Only Links' or 'Only Archives')
character_filter_should_be_active = not is_only_links and not is_only_archives
if self.character_filter_widget:
self.character_filter_widget.setVisible(character_filter_should_be_active)
# Enable/disable character input and its scope button based on whether character filtering is active
enable_character_filter_related_widgets = character_filter_should_be_active
if self.character_input: if self.character_input:
self.character_input.setEnabled(enable_character_filter_related_widgets) self.character_input.setEnabled(enable_character_filter_related_widgets)
@@ -2082,9 +1905,7 @@ class DownloaderApp(QWidget):
if self.char_filter_scope_toggle_button: if self.char_filter_scope_toggle_button:
self.char_filter_scope_toggle_button.setEnabled(enable_character_filter_related_widgets) self.char_filter_scope_toggle_button.setEnabled(enable_character_filter_related_widgets)
# Call update_ui_for_subfolders to correctly set the "Subfolder per Post" checkbox state self.update_ui_for_subfolders(subfolders_on)
# and "Custom Folder Name" visibility, which DO depend on the "Separate Folders" checkbox.
self.update_ui_for_subfolders(subfolders_on) # Pass the current state of the main subfolder checkbox
self.update_custom_folder_visibility() self.update_custom_folder_visibility()
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False) self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
@@ -2304,53 +2125,16 @@ class DownloaderApp(QWidget):
def _handle_ui_add_new_character(self): def _handle_ui_add_new_character(self):
"""Handles adding a new character from the UI input field.""" """Handles adding a new character from the UI input field."""
name_from_ui_input = self.new_char_input.text().strip() name_from_ui_input = self.new_char_input.text().strip()
successfully_added_any = False
if not name_from_ui_input: if not name_from_ui_input:
QMessageBox.warning(self, "Input Error", "Name cannot be empty.") QMessageBox.warning(self, "Input Error", "Name cannot be empty.")
return return
if name_from_ui_input.startswith("(") and name_from_ui_input.endswith(")~"): # For UI additions, it's always a simple, non-group entry.
# Format: (Name1, Name2)~ -> Group "Name1 Name2" with aliases Name1, Name2 # The special ( ) and ( )~ parsing is for the "Filter by Character(s)" field.
content = name_from_ui_input[1:-2].strip() # Remove ( and )~ self.add_new_character(name_to_add=name_from_ui_input,
aliases = [alias.strip() for alias in content.split(',') if alias.strip()]
if aliases:
folder_name = " ".join(aliases) # The primary name for the KNOWN_NAMES entry
if self.add_new_character(name_to_add=folder_name,
is_group_to_add=True,
aliases_to_add=aliases,
suppress_similarity_prompt=False):
successfully_added_any = True
else:
QMessageBox.warning(self, "Input Error", "Empty group content for `~` format.")
elif name_from_ui_input.startswith("(") and name_from_ui_input.endswith(")"):
# Format: (Name1, Name2) -> Add Name1 and Name2 as separate entries
content = name_from_ui_input[1:-1].strip() # Remove ( and )
names_to_add_separately = [name.strip() for name in content.split(',') if name.strip()]
if names_to_add_separately:
for name_item in names_to_add_separately:
if self.add_new_character(name_to_add=name_item,
is_group_to_add=False,
aliases_to_add=[name_item],
suppress_similarity_prompt=False):
successfully_added_any = True
else:
QMessageBox.warning(self, "Input Error", "Empty group content for standard group format.")
else:
# Simple name, add as a single non-group entry
if self.add_new_character(name_to_add=name_from_ui_input,
is_group_to_add=False, is_group_to_add=False,
aliases_to_add=[name_from_ui_input], aliases_to_add=[name_from_ui_input],
suppress_similarity_prompt=False): suppress_similarity_prompt=False) # UI adds one by one, so prompt is fine
successfully_added_any = True
if successfully_added_any:
self.new_char_input.clear()
self.save_known_names()
# The add_new_character method itself handles logging success/failure of individual additions
# and updating the character_list widget.
def add_new_character(self, name_to_add, is_group_to_add, aliases_to_add, suppress_similarity_prompt=False): def add_new_character(self, name_to_add, is_group_to_add, aliases_to_add, suppress_similarity_prompt=False):
global KNOWN_NAMES, clean_folder_name global KNOWN_NAMES, clean_folder_name
@@ -2424,6 +2208,7 @@ class DownloaderApp(QWidget):
log_msg_suffix = f" (as group with aliases: {', '.join(new_entry['aliases'])})" if is_group_to_add and len(new_entry['aliases']) > 1 else "" log_msg_suffix = f" (as group with aliases: {', '.join(new_entry['aliases'])})" if is_group_to_add and len(new_entry['aliases']) > 1 else ""
self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list{log_msg_suffix}.") self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list{log_msg_suffix}.")
self.new_char_input.clear() self.new_char_input.clear()
self.save_known_names()
return True return True
@@ -2475,17 +2260,27 @@ class DownloaderApp(QWidget):
if self.custom_folder_input: self.custom_folder_input.clear() if self.custom_folder_input: self.custom_folder_input.clear()
def update_ui_for_subfolders(self, separate_folders_by_name_title_checked: bool): def update_ui_for_subfolders(self, checked):
is_only_links = self.radio_only_links and self.radio_only_links.isChecked() is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked() is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked()
can_enable_subfolder_per_post_checkbox = not is_only_links and not is_only_archives
if self.use_subfolder_per_post_checkbox: if self.use_subfolder_per_post_checkbox:
self.use_subfolder_per_post_checkbox.setEnabled(can_enable_subfolder_per_post_checkbox) self.use_subfolder_per_post_checkbox.setEnabled(not is_only_links and not is_only_archives)
if not can_enable_subfolder_per_post_checkbox: if hasattr(self, 'use_cookie_checkbox'):
self.use_subfolder_per_post_checkbox.setChecked(False) self.use_cookie_checkbox.setEnabled(not is_only_links) # Cookies might be relevant for archives
enable_character_filter_related_widgets = checked and not is_only_links and not is_only_archives
if self.character_filter_widget:
self.character_filter_widget.setVisible(enable_character_filter_related_widgets)
if not self.character_filter_widget.isVisible():
if self.character_input: self.character_input.clear()
if self.char_filter_scope_toggle_button: self.char_filter_scope_toggle_button.setEnabled(False)
else:
if self.char_filter_scope_toggle_button: self.char_filter_scope_toggle_button.setEnabled(True)
self.update_custom_folder_visibility() self.update_custom_folder_visibility()
@@ -2523,12 +2318,12 @@ class DownloaderApp(QWidget):
_, _, post_id = extract_post_info(url_text) _, _, post_id = extract_post_info(url_text)
is_creator_feed = not post_id if url_text else False is_creator_feed = not post_id if url_text else False
# Manga mode no longer directly dictates page range enabled state. manga_mode_active = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
# Page range is enabled if it's a creator feed.
enable_page_range = is_creator_feed enable_page_range = is_creator_feed and not manga_mode_active
for widget in [self.page_range_label, self.start_page_input, self.to_label, self.end_page_input]: for widget in [self.page_range_label, self.start_page_input, self.to_label, self.end_page_input]:
if widget: widget.setEnabled(enable_page_range) # Enable/disable based on whether it's a creator feed if widget: widget.setEnabled(enable_page_range)
if not enable_page_range: if not enable_page_range:
if self.start_page_input: self.start_page_input.clear() if self.start_page_input: self.start_page_input.clear()
@@ -2561,18 +2356,6 @@ class DownloaderApp(QWidget):
" Downloads as: \"001.jpg\", \"002.jpg\".\n\n" " Downloads as: \"001.jpg\", \"002.jpg\".\n\n"
"Click to change to: Post Title" "Click to change to: Post Title"
) )
elif self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING:
self.manga_rename_toggle_button.setText("Name: Title+G.Num")
self.manga_rename_toggle_button.setToolTip(
"Manga Filename Style: Post Title + Global Numbering\n\n"
"When Manga/Comic Mode is active for a creator feed:\n"
"- All files across all posts in the current download session are named sequentially using the post's title as a prefix.\n"
"- Example: Post 'Chapter 1' (2 files) -> 'Chapter 1_001.jpg', 'Chapter 1_002.png'.\n"
" Next Post 'Chapter 2' (1 file) -> 'Chapter 2_003.jpg'.\n"
"- Multithreading for post processing is automatically disabled for this style.\n\n"
"Click to change to: Post Title"
)
elif self.manga_filename_style == STYLE_DATE_BASED: elif self.manga_filename_style == STYLE_DATE_BASED:
self.manga_rename_toggle_button.setText("Name: Date Based") self.manga_rename_toggle_button.setText("Name: Date Based")
self.manga_rename_toggle_button.setToolTip( self.manga_rename_toggle_button.setToolTip(
@@ -2599,10 +2382,8 @@ class DownloaderApp(QWidget):
if current_style == STYLE_POST_TITLE: # Title -> Original if current_style == STYLE_POST_TITLE: # Title -> Original
new_style = STYLE_ORIGINAL_NAME new_style = STYLE_ORIGINAL_NAME
elif current_style == STYLE_ORIGINAL_NAME: # Original -> Date elif current_style == STYLE_ORIGINAL_NAME: # Original -> Date
new_style = STYLE_POST_TITLE_GLOBAL_NUMBERING # Original -> Title+GlobalNum
elif current_style == STYLE_POST_TITLE_GLOBAL_NUMBERING: # Title+GlobalNum -> Date Based
new_style = STYLE_DATE_BASED new_style = STYLE_DATE_BASED
elif current_style == STYLE_DATE_BASED: # Date Based -> Title elif current_style == STYLE_DATE_BASED: # Date -> Title
new_style = STYLE_POST_TITLE new_style = STYLE_POST_TITLE
else: else:
self.log_signal.emit(f"⚠️ Unknown current manga filename style: {current_style}. Resetting to default ('{STYLE_POST_TITLE}').") self.log_signal.emit(f"⚠️ Unknown current manga filename style: {current_style}. Resetting to default ('{STYLE_POST_TITLE}').")
@@ -2635,20 +2416,24 @@ class DownloaderApp(QWidget):
if self.manga_rename_toggle_button: if self.manga_rename_toggle_button:
self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode)) self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode))
# Always update page range enabled state, as it depends on URL type, not directly manga mode.
if manga_mode_effectively_on:
if self.page_range_label: self.page_range_label.setEnabled(False)
if self.start_page_input: self.start_page_input.setEnabled(False); self.start_page_input.clear()
if self.to_label: self.to_label.setEnabled(False)
if self.end_page_input: self.end_page_input.setEnabled(False); self.end_page_input.clear()
else:
self.update_page_range_enabled_state() self.update_page_range_enabled_state()
file_download_mode_active = not (self.radio_only_links and self.radio_only_links.isChecked()) file_download_mode_active = not (self.radio_only_links and self.radio_only_links.isChecked())
# Character filter widgets should be enabled if it's a file download mode subfolders_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
enable_char_filter_widgets = file_download_mode_active and not (self.radio_only_archives and self.radio_only_archives.isChecked()) enable_char_filter_widgets = file_download_mode_active and (subfolders_on or manga_mode_effectively_on)
if self.character_input: if self.character_input:
self.character_input.setEnabled(enable_char_filter_widgets) self.character_input.setEnabled(enable_char_filter_widgets)
if not enable_char_filter_widgets: self.character_input.clear() if not enable_char_filter_widgets: self.character_input.clear()
if self.char_filter_scope_toggle_button: if self.char_filter_scope_toggle_button:
self.char_filter_scope_toggle_button.setEnabled(enable_char_filter_widgets) self.char_filter_scope_toggle_button.setEnabled(enable_char_filter_widgets)
if self.character_filter_widget: # Also ensure the main widget visibility is correct
self.character_filter_widget.setVisible(enable_char_filter_widgets)
self._update_multithreading_for_date_mode() # Update multithreading state based on manga mode self._update_multithreading_for_date_mode() # Update multithreading state based on manga mode
@@ -2663,7 +2448,7 @@ class DownloaderApp(QWidget):
if self.use_multithreading_checkbox.isChecked(): if self.use_multithreading_checkbox.isChecked():
try: try:
num_threads_val = int(text) num_threads_val = int(text)
if num_threads_val > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads_val} Threads)") # type: ignore if num_threads_val > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads_val} Threads)")
else: self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)") else: self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)")
except ValueError: except ValueError:
self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)") self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)")
@@ -2689,12 +2474,10 @@ class DownloaderApp(QWidget):
if not hasattr(self, 'manga_mode_checkbox') or not hasattr(self, 'use_multithreading_checkbox'): if not hasattr(self, 'manga_mode_checkbox') or not hasattr(self, 'use_multithreading_checkbox'):
return # UI elements not ready return # UI elements not ready
manga_on = self.manga_mode_checkbox.isChecked() # type: ignore manga_on = self.manga_mode_checkbox.isChecked()
is_sequential_style_requiring_single_thread = ( is_date_style = (self.manga_filename_style == STYLE_DATE_BASED)
self.manga_filename_style == STYLE_DATE_BASED or
self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING if manga_on and is_date_style:
)
if manga_on and is_sequential_style_requiring_single_thread:
if self.use_multithreading_checkbox.isChecked() or self.use_multithreading_checkbox.isEnabled(): if self.use_multithreading_checkbox.isChecked() or self.use_multithreading_checkbox.isEnabled():
if self.use_multithreading_checkbox.isChecked(): if self.use_multithreading_checkbox.isChecked():
self.log_signal.emit(" Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.") self.log_signal.emit(" Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.")
@@ -2835,42 +2618,16 @@ class DownloaderApp(QWidget):
start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip() start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip()
start_page, end_page = None, None start_page, end_page = None, None
is_creator_feed = bool(not post_id_from_url) is_creator_feed = bool(not post_id_from_url)
if is_creator_feed and not manga_mode:
if is_creator_feed: # Page range is only relevant and parsed for creator feeds
try: try:
if start_page_str: start_page = int(start_page_str) if start_page_str: start_page = int(start_page_str)
if end_page_str: end_page = int(end_page_str) if end_page_str: end_page = int(end_page_str)
# Validate parsed page numbers
if start_page is not None and start_page <= 0: raise ValueError("Start page must be positive.") if start_page is not None and start_page <= 0: raise ValueError("Start page must be positive.")
if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.") if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.")
if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.") if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.")
except ValueError as e: QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}"); return
# If it's a creator feed, and manga mode is on, and both page fields were filled, show warning elif manga_mode:
if manga_mode and start_page and end_page: start_page, end_page = None, None
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Manga Mode & Page Range Warning")
msg_box.setText(
"You have enabled <b>Manga/Comic Mode</b> and also specified a <b>Page Range</b>.\n\n"
"Manga Mode processes posts from oldest to newest across all available pages by default.\n"
"If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n"
"However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n"
"Do you want to proceed with this page range in Manga Mode?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
msg_box.setDefaultButton(proceed_button)
msg_box.setEscapeButton(cancel_button)
msg_box.exec_()
if msg_box.clickedButton() == cancel_button:
self.log_signal.emit("❌ Download cancelled by user due to Manga Mode & Page Range warning.")
self.set_ui_enabled(True); return # Re-enable UI and stop
except ValueError as e:
QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}")
self.set_ui_enabled(True); return # Re-enable UI and stop
# If not a creator_feed, start_page and end_page remain None.
self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None
raw_character_filters_text = self.character_input.text().strip() # Get current text raw_character_filters_text = self.character_input.text().strip() # Get current text
@@ -2931,22 +2688,7 @@ class DownloaderApp(QWidget):
elif isinstance(dialog_result, list): # User chose to add selected items elif isinstance(dialog_result, list): # User chose to add selected items
if dialog_result: # If the list of selected filter_objects is not empty if dialog_result: # If the list of selected filter_objects is not empty
self.log_signal.emit(f" User chose to add {len(dialog_result)} new entry/entries to Known.txt.") self.log_signal.emit(f" User chose to add {len(dialog_result)} new entry/entries to Known.txt.")
for filter_obj_to_add in dialog_result: # dialog_result is the list of selected filter_obj from ConfirmAddAllDialog for filter_obj_to_add in dialog_result: # dialog_result is the list of selected filter_obj
if filter_obj_to_add.get("components_are_distinct_for_known_txt"):
# This was a (A, B, C) group. Add A, B, C separately to Known.txt.
# The dialog presented the group name (e.g., "Power Reze Himeno") for selection.
# Now, we iterate its components (aliases) for individual Known.txt addition.
self.log_signal.emit(f" Processing group '{filter_obj_to_add['name']}' to add its components individually to Known.txt.")
for alias_component in filter_obj_to_add["aliases"]:
self.add_new_character(
name_to_add=alias_component,
is_group_to_add=False, # Add as individual non-group entry
aliases_to_add=[alias_component], # Alias is itself
suppress_similarity_prompt=True # Suppress for batch adding
)
else:
# This is a tilde group (A,B,C)~ or a simple name "Tifa"
# Add to Known.txt as is (either a group or a simple name).
self.add_new_character( self.add_new_character(
name_to_add=filter_obj_to_add["name"], name_to_add=filter_obj_to_add["name"],
is_group_to_add=filter_obj_to_add["is_group"], is_group_to_add=filter_obj_to_add["is_group"],
@@ -3005,27 +2747,17 @@ class DownloaderApp(QWidget):
self.retryable_failed_files_info.clear() # Clear previous retryable failures before new session self.retryable_failed_files_info.clear() # Clear previous retryable failures before new session
manga_date_file_counter_ref_for_thread = None manga_date_file_counter_ref_for_thread = None
if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not extract_links_only: if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not extract_links_only:
# Initialization for STYLE_DATE_BASED (scanning existing files) happens in DownloadThread.run manga_date_file_counter_ref_for_thread = None
manga_date_file_counter_ref_for_thread = None # Placeholder, actual init in thread
self.log_signal.emit(f" Manga Date Mode: File counter will be initialized by the download thread.") self.log_signal.emit(f" Manga Date Mode: File counter will be initialized by the download thread.")
manga_global_file_counter_ref_for_thread = None
if manga_mode and self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING and not extract_links_only:
manga_global_file_counter_ref_for_thread = None # Placeholder, actual init in thread
self.log_signal.emit(f" Manga Title+GlobalNum Mode: File counter will be initialized by the download thread (starts at 1).")
effective_num_post_workers = 1 effective_num_post_workers = 1
effective_num_file_threads_per_worker = 1 # Default to 1 for all cases initially effective_num_file_threads_per_worker = 1 # Default to 1 for all cases initially
if post_id_from_url: if post_id_from_url:
if use_multithreading_enabled_by_checkbox: if use_multithreading_enabled_by_checkbox:
effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER)) effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER))
else: # This is the outer else block else:
if manga_mode and self.manga_filename_style == STYLE_DATE_BASED: if manga_mode and self.manga_filename_style == STYLE_DATE_BASED:
effective_num_post_workers = 1 effective_num_post_workers = 1
elif manga_mode and self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING: # Correctly indented elif
effective_num_post_workers = 1
effective_num_file_threads_per_worker = 1 # Files are sequential for this worker too effective_num_file_threads_per_worker = 1 # Files are sequential for this worker too
elif use_multithreading_enabled_by_checkbox: # Standard creator feed with multithreading enabled elif use_multithreading_enabled_by_checkbox: # Standard creator feed with multithreading enabled
effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) # For posts effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) # For posts
@@ -3041,14 +2773,12 @@ class DownloaderApp(QWidget):
log_messages.append(f" Mode: Creator Feed") log_messages.append(f" Mode: Creator Feed")
log_messages.append(f" Post Processing: {'Multi-threaded (' + str(effective_num_post_workers) + ' workers)' if effective_num_post_workers > 1 else 'Single-threaded (1 worker)'}") log_messages.append(f" Post Processing: {'Multi-threaded (' + str(effective_num_post_workers) + ' workers)' if effective_num_post_workers > 1 else 'Single-threaded (1 worker)'}")
log_messages.append(f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker} concurrent file(s)") log_messages.append(f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker} concurrent file(s)")
# Logging for page range (applies if is_creator_feed is true) if is_creator_feed:
if manga_mode: log_messages.append(" Page Range: All (Manga Mode - Oldest Posts Processed First)")
else:
pr_log = "All" pr_log = "All"
if start_page or end_page: # Construct pr_log if start_page or end_page have values if start_page or end_page:
pr_log = f"{f'From {start_page} ' if start_page else ''}{'to ' if start_page and end_page else ''}{f'{end_page}' if end_page else (f'Up to {end_page}' if end_page else (f'From {start_page}' if start_page else 'Specific Range'))}".strip() pr_log = f"{f'From {start_page} ' if start_page else ''}{'to ' if start_page and end_page else ''}{f'{end_page}' if end_page else (f'Up to {end_page}' if end_page else (f'From {start_page}' if start_page else 'Specific Range'))}".strip()
if manga_mode:
log_messages.append(f" Page Range: {pr_log if pr_log else 'All'} (Manga Mode - Oldest Posts Processed First within range)")
else: # Not manga mode, but still a creator feed
log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}") log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}")
@@ -3090,9 +2820,8 @@ class DownloaderApp(QWidget):
elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend: elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend:
log_messages.append(f" ↳ Cookie File Selected: {os.path.basename(selected_cookie_file_path_for_backend)}") log_messages.append(f" ↳ Cookie File Selected: {os.path.basename(selected_cookie_file_path_for_backend)}")
should_use_multithreading_for_posts = use_multithreading_enabled_by_checkbox and not post_id_from_url should_use_multithreading_for_posts = use_multithreading_enabled_by_checkbox and not post_id_from_url
if manga_mode and (self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING) and not post_id_from_url: if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not post_id_from_url:
enforced_by_style = "Date Mode" if self.manga_filename_style == STYLE_DATE_BASED else "Title+GlobalNum Mode" log_messages.append(f" Threading: Single-threaded (posts) - Enforced by Manga Date Mode")
log_messages.append(f" Threading: Single-threaded (posts) - Enforced by Manga {enforced_by_style}")
should_use_multithreading_for_posts = False # Ensure this reflects the forced state should_use_multithreading_for_posts = False # Ensure this reflects the forced state
else: else:
log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading_for_posts else 'Single-threaded (posts)'}") log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading_for_posts else 'Single-threaded (posts)'}")
@@ -3146,7 +2875,6 @@ class DownloaderApp(QWidget):
'allow_multipart_download': allow_multipart, 'allow_multipart_download': allow_multipart,
'cookie_text': cookie_text_from_input, # Pass cookie text 'cookie_text': cookie_text_from_input, # Pass cookie text
'selected_cookie_file': selected_cookie_file_path_for_backend, # Pass selected cookie file 'selected_cookie_file': selected_cookie_file_path_for_backend, # Pass selected cookie file
'manga_global_file_counter_ref': manga_global_file_counter_ref_for_thread, # Pass new counter
'app_base_dir': app_base_dir_for_cookies, # Pass app base dir 'app_base_dir': app_base_dir_for_cookies, # Pass app base dir
'use_cookie': use_cookie_from_checkbox, # Pass cookie setting 'use_cookie': use_cookie_from_checkbox, # Pass cookie setting
} }
@@ -3168,8 +2896,7 @@ class DownloaderApp(QWidget):
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'skip_words_list', 'skip_words_scope', 'char_filter_scope',
'show_external_links', 'extract_links_only', 'num_file_threads_for_worker', 'show_external_links', 'extract_links_only', 'num_file_threads_for_worker',
'start_page', 'end_page', 'target_post_id_from_initial_url', 'start_page', 'end_page', 'target_post_id_from_initial_url',
'manga_date_file_counter_ref', 'manga_date_file_counter_ref', # Ensure this is passed for single thread mode
'manga_global_file_counter_ref', # Pass new counter for single thread mode
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'manga_mode_active', 'unwanted_keywords', 'manga_filename_style',
'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file
] ]
@@ -3366,8 +3093,7 @@ class DownloaderApp(QWidget):
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'skip_words_list', 'skip_words_scope', 'char_filter_scope',
'show_external_links', 'extract_links_only', 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', # Added selected_cookie_file 'show_external_links', 'extract_links_only', 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', # Added selected_cookie_file
'num_file_threads', 'skip_current_file_flag', 'manga_date_file_counter_ref', 'num_file_threads', 'skip_current_file_flag', 'manga_date_file_counter_ref',
'manga_mode_active', 'manga_filename_style', 'manga_mode_active', 'manga_filename_style'
'manga_global_file_counter_ref' # Add new counter here
] ]
ppw_optional_keys_with_defaults = { ppw_optional_keys_with_defaults = {
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list', 'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list',
@@ -3375,8 +3101,6 @@ class DownloaderApp(QWidget):
'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style', 'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style',
'manga_date_file_counter_ref', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file 'manga_date_file_counter_ref', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file
} }
# Batching is generally for high worker counts.
# If num_post_workers is low (e.g., 1), the num_post_workers > POST_WORKER_BATCH_THRESHOLD condition will prevent batching.
if num_post_workers > POST_WORKER_BATCH_THRESHOLD and self.total_posts_to_process > POST_WORKER_NUM_BATCHES : if num_post_workers > POST_WORKER_BATCH_THRESHOLD and self.total_posts_to_process > POST_WORKER_NUM_BATCHES :
self.log_signal.emit(f" High thread count ({num_post_workers}) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES} parts.") self.log_signal.emit(f" High thread count ({num_post_workers}) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES} parts.")
@@ -3995,261 +3719,6 @@ class DownloaderApp(QWidget):
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(False) self.update_ui_for_manga_mode(False)
def _show_feature_guide(self):
# Define content for each page
page1_title = "① Introduction & Main Inputs"
page1_content = """<html><head/><body>
<p>This guide provides an overview of the Kemono Downloader's features, fields, and buttons.</p>
<h3>Main Input Area (Top Left)</h3>
<ul>
<li><b>🔗 Kemono Creator/Post URL:</b>
<ul>
<li>Enter the full web address of a creator's page (e.g., <i>https://kemono.su/patreon/user/12345</i>) or a specific post (e.g., <i>.../post/98765</i>).</li>
<li>Supports Kemono (kemono.su, kemono.party) and Coomer (coomer.su, coomer.party) URLs.</li>
</ul>
</li>
<li><b>Page Range (Start to End):</b>
<ul>
<li>For creator URLs: Specify a range of pages to fetch (e.g., pages 2 to 5). Leave blank for all pages.</li>
<li>Disabled for single post URLs or when <b>Manga/Comic Mode</b> is active.</li>
</ul>
</li>
<li><b>📁 Download Location:</b>
<ul>
<li>Click <b>'Browse...'</b> to choose a main folder on your computer where all downloaded files will be saved.</li>
<li>This field is required unless you are using <b>'🔗 Only Links'</b> mode.</li>
</ul>
</li>
</ul></body></html>"""
page2_title = "② Filtering Downloads"
page2_content = """<html><head/><body>
<h3>Filtering Downloads (Left Panel)</h3>
<ul>
<li><b>🎯 Filter by Character(s):</b>
<ul>
<li>Enter names, comma-separated (e.g., <code>Tifa, Aerith</code>).</li>
<li><b>Grouped Aliases for Shared Folder (Separate Known.txt Entries):</b> <code>(Vivi, Ulti, Uta)</code>.
<ul><li>Content matching "Vivi", "Ulti", OR "Uta" will go into a shared folder named "Vivi Ulti Uta" (after cleaning).</li>
<li>If these names are new, "Vivi", "Ulti", and "Uta" will be prompted to be added as <i>separate individual entries</i> to <code>Known.txt</code>.</li>
</ul>
</li>
<li><b>Grouped Aliases for Shared Folder (Single Known.txt Entry):</b> <code>(Yuffie, Sonon)~</code> (note the tilde <code>~</code>).
<ul><li>Content matching "Yuffie" OR "Sonon" will go into a shared folder named "Yuffie Sonon".</li>
<li>If new, "Yuffie Sonon" (with aliases Yuffie, Sonon) will be prompted to be added as a <i>single group entry</i> to <code>Known.txt</code>.</li>
</ul>
</li>
<li>This filter influences folder naming if 'Separate Folders by Name/Title' is enabled.</li>
</ul>
</li>
<li><b>Filter: [Type] Button (Character Filter Scope):</b> Cycles how the 'Filter by Character(s)' applies:
<ul>
<li><code>Filter: Files</code>: Checks individual filenames. A post is kept if any file matches; only matching files are downloaded. Folder naming uses the character from the matching filename.</li>
<li><code>Filter: Title</code>: Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title.</li>
<li><code>Filter: Both</code>: Checks post title first. If it matches, all files are downloaded. If not, it then checks filenames, and only matching files are downloaded. Folder naming prioritizes title match, then file match.</li>
<li><code>Filter: Comments (Beta)</code>: Checks filenames first. If a file matches, all files from the post are downloaded. If no file match, it then checks post comments. If a comment matches, all files are downloaded. (Uses more API requests). Folder naming prioritizes file match, then comment match.</li>
</ul>
</li>
<li><b>🗄️ Custom Folder Name (Single Post Only):</b>
<ul>
<li>Visible and usable only when downloading a single specific post URL AND 'Separate Folders by Name/Title' is enabled.</li>
<li>Allows you to specify a custom name for that single post's download folder.</li>
</ul>
</li>
<li><b>🚫 Skip with Words:</b>
<ul><li>Enter words, comma-separated (e.g., <code>WIP, sketch, preview</code>) to skip certain content.</li></ul>
</li>
<li><b>Scope: [Type] Button (Skip Words Scope):</b> Cycles how 'Skip with Words' applies:
<ul>
<li><code>Scope: Files</code>: Skips individual files if their names contain any of these words.</li>
<li><code>Scope: Posts</code>: Skips entire posts if their titles contain any of these words.</li>
<li><code>Scope: Both</code>: Applies both (post title first, then individual files).</li>
</ul>
</li>
<li><b>✂️ Remove Words from name:</b>
<ul><li>Enter words, comma-separated (e.g., <code>patreon, [HD]</code>), to remove from downloaded filenames (case-insensitive).</li></ul>
</li>
<li><b>Filter Files (Radio Buttons):</b> Choose what to download:
<ul>
<li><code>All</code>: Downloads all file types found.</li>
<li><code>Images/GIFs</code>: Only common image formats (JPG, PNG, GIF, WEBP, etc.) and GIFs.</li>
<li><code>Videos</code>: Only common video formats (MP4, MKV, WEBM, MOV, etc.).</li>
<li><code>📦 Only Archives</code>: Exclusively downloads <b>.zip</b> and <b>.rar</b> files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show External Links' is also disabled.</li>
<li><code>🔗 Only Links</code>: Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show External Links' are disabled. The main download button changes to '🔗 Extract Links'.</li>
</ul>
</li>
</ul></body></html>"""
page3_title = "③ Download Options & Settings"
page3_content = """<html><head/><body>
<h3>Download Options & Settings (Left Panel)</h3>
<ul>
<li><b>Skip .zip / Skip .rar:</b> Checkboxes to avoid downloading these archive file types. (Disabled and ignored if '📦 Only Archives' filter mode is selected).</li>
<li><b>Download Thumbnails Only:</b> Downloads small preview images instead of full-sized files (if available).</li>
<li><b>Compress Large Images (to WebP):</b> If the 'Pillow' (PIL) library is installed, images larger than 1.5MB will be converted to WebP format if the WebP version is significantly smaller.</li>
<li><b>⚙️ Advanced Settings:</b>
<ul>
<li><b>Separate Folders by Name/Title:</b> Creates subfolders based on the 'Filter by Character(s)' input or post titles. Can use the <b>Known.txt</b> list as a fallback for folder names.</li></ul></li></ul></body></html>"""
page4_title = "④ Advanced Settings (Part 1)"
page4_content = """<html><head/><body><h3>⚙️ Advanced Settings (Continued)</h3><ul><ul>
<!-- Continuing from previous page's ul for Advanced Settings -->
<li><b>Subfolder per Post:</b> If 'Separate Folders' is on, this creates an additional subfolder for <i>each individual post</i> inside the main character/title folder.</li>
<li><b>Use Cookie:</b> Check this to use cookies for requests.
<ul>
<li><b>Text Field:</b> Enter a cookie string directly (e.g., <code>name1=value1; name2=value2</code>).</li>
<li><b>Browse...:</b> Select a <code>cookies.txt</code> file (Netscape format). The path will appear in the text field.</li>
<li><b>Precedence:</b> The text field (if filled) takes precedence over a browsed file. If 'Use Cookie' is checked but both are empty, it attempts to load <code>cookies.txt</code> from the app's directory.</li>
</ul>
</li>
<li><b>Use Multithreading & Threads Input:</b>
<ul>
<li>Enables faster operations. The number in 'Threads' input means:
<ul>
<li>For <b>Creator Feeds:</b> Number of posts to process simultaneously. Files within each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).</li>
<li>For <b>Single Post URLs:</b> Number of files to download concurrently from that single post.</li>
</ul>
</li>
<li>If unchecked, 1 thread is used. High thread counts (e.g., >40) may show an advisory.</li>
</ul>
</li></ul></ul></body></html>"""
page5_title = "⑤ Advanced Settings (Part 2) & Actions"
page5_content = """<html><head/><body><h3>⚙️ Advanced Settings (Continued)</h3><ul><ul>
<!-- Continuing from previous page's ul for Advanced Settings -->
<li><b>Show External Links in Log:</b> If checked, a secondary log panel appears below the main log to display any external links found in post descriptions. (Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).</li>
<li><b>📖 Manga/Comic Mode (Creator URLs only):</b> Tailored for sequential content.
<ul>
<li>Downloads posts from <b>oldest to newest</b>.</li>
<li>The 'Page Range' input is disabled as all posts are fetched.</li>
<li>A <b>filename style toggle button</b> (e.g., 'Name: Post Title') appears in the top-right of the log area when this mode is active for a creator feed. Click it to cycle through naming styles:
<ul>
<li><code>Name: Post Title (Default)</code>: The first file in a post is named after the post's title. Subsequent files in the same post keep original names.</li>
<li><code>Name: Original File</code>: All files attempt to keep their original filenames.</li>
<li><code>Name: Date Based</code>: Files are named sequentially (001.ext, 002.ext, ...) based on post publication order. Multithreading for post processing is automatically disabled for this style.</li>
</ul>
</li>
<li>For best results with 'Name: Post Title' or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.</li>
</ul>
</li>
</ul></li></ul>
<h3>Main Action Buttons (Left Panel)</h3>
<ul>
<li><b>⬇️ Start Download / 🔗 Extract Links:</b> This button's text and function change based on the 'Filter Files' radio button selection. It starts the primary operation.</li>
<li><b>⏸️ Pause Download / ▶️ Resume Download:</b> Allows you to temporarily halt the current download/extraction process and resume it later. Some UI settings can be changed while paused.</li>
<li><b>❌ Cancel & Reset UI:</b> Stops the current operation and performs a soft UI reset. Your URL and Download Directory inputs are preserved, but other settings and logs are cleared.</li>
</ul></body></html>"""
page6_title = "⑥ Known Shows/Characters List"
page6_content = """<html><head/><body>
<h3>Known Shows/Characters List Management (Bottom Left)</h3>
<p>This section helps manage the <code>Known.txt</code> file, which is used for smart folder organization when 'Separate Folders by Name/Title' is enabled, especially as a fallback if a post doesn't match your active 'Filter by Character(s)' input.</p>
<ul>
<li><b>Open Known.txt:</b> Opens the <code>Known.txt</code> file (located in the app's directory) in your default text editor for advanced editing (like creating complex grouped aliases).</li>
<li><b>Search characters...:</b> Filters the list of known names displayed below.</li>
<li><b>List Widget:</b> Displays the primary names from your <code>Known.txt</code>. Select entries here to delete them.</li>
<li><b>Add new show/character name (Input Field):</b> Enter a name or group to add.
<ul>
<li><b>Simple Name:</b> e.g., <code>My Awesome Series</code>. Adds as a single entry.</li>
<li><b>Group for Separate Known.txt Entries:</b> e.g., <code>(Vivi, Ulti, Uta)</code>. Adds "Vivi", "Ulti", and "Uta" as three separate individual entries to <code>Known.txt</code>.</li>
<li><b>Group for Shared Folder & Single Known.txt Entry (Tilde <code>~</code>):</b> e.g., <code>(Character A, Char A)~</code>. Adds one entry to <code>Known.txt</code> named "Character A Char A". "Character A" and "Char A" become aliases for this single folder/entry.</li>
</ul>
</li>
<li><b> Add Button:</b> Adds the name/group from the input field above to the list and <code>Known.txt</code>.</li>
<li><b>🗑️ Delete Selected Button:</b> Deletes the selected name(s) from the list and <code>Known.txt</code>.</li>
<li><b>❓ Button (This one!):</b> Displays this comprehensive help guide.</li>
</ul></body></html>"""
page7_title = "⑦ Log Area & Controls"
page7_content = """<html><head/><body>
<h3>Log Area & Controls (Right Panel)</h3>
<ul>
<li><b>📜 Progress Log / Extracted Links Log (Label):</b> Title for the main log area; changes if '🔗 Only Links' mode is active.</li>
<li><b>Search Links... / 🔍 Button (Link Search):</b>
<ul><li>Visible only when '🔗 Only Links' mode is active. Allows real-time filtering of the extracted links displayed in the main log by text, URL, or platform.</li></ul>
</li>
<li><b>Name: [Style] Button (Manga Filename Style):</b>
<ul><li>Visible only when <b>Manga/Comic Mode</b> is active for a creator feed and not in 'Only Links' or 'Only Archives' mode.</li>
<li>Cycles through filename styles: <code>Post Title</code>, <code>Original File</code>, <code>Date Based</code>. (See Manga/Comic Mode section for details).</li>
</ul>
</li>
<li><b>Multi-part: [ON/OFF] Button:</b>
<ul><li>Toggles multi-segment downloads for individual large files.
<ul><li><b>ON:</b> Can speed up large file downloads but may increase UI choppiness or log spam with many small files. An advisory appears when enabling. If a multi-part download fails, it retries as single-stream.</li>
<li><b>OFF (Default):</b> Files are downloaded in a single stream.</li>
</ul>
<li>Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active.</li>
</ul>
</li>
<li><b>👁️ / 🙈 Button (Log View Toggle):</b> Switches the main log view:
<ul>
<li><b>👁️ Progress Log (Default):</b> Shows all download activity, errors, and summaries.</li>
<li><b>🙈 Missed Character Log:</b> Displays a list of key terms from post titles/content that were skipped due to your 'Filter by Character(s)' settings. Useful for identifying content you might be unintentionally missing.</li>
</ul>
</li>
<li><b>🔄 Reset Button:</b> Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.</li>
<li><b>Main Log Output (Text Area):</b> Displays detailed progress messages, errors, and summaries. If '🔗 Only Links' mode is active, this area displays the extracted links.</li>
<li><b>Missed Character Log Output (Text Area):</b> (Viewable via 👁️ / 🙈 toggle) Displays posts/files skipped due to character filters.</li>
<li><b>External Log Output (Text Area):</b> Appears below the main log if 'Show External Links in Log' is checked. Displays external links found in post descriptions.</li>
<li><b>Export Links Button:</b>
<ul><li>Visible and enabled only when '🔗 Only Links' mode is active and links have been extracted.</li>
<li>Allows you to save all extracted links to a <code>.txt</code> file.</li>
</ul>
</li>
<li><b>Progress: [Status] Label:</b> Shows the overall progress of the download or link extraction process (e.g., posts processed).</li>
<li><b>File Progress Label:</b> Shows the progress of individual file downloads, including speed and size, or multi-part download status.</li>
</ul></body></html>"""
page8_title = "⑧ Key Files & Tour"
page8_content = """<html><head/><body>
<h3>Key Files Used by the Application</h3>
<ul>
<li><b><code>Known.txt</code>:</b>
<ul>
<li>Located in the application's directory (where the <code>.exe</code> or <code>main.py</code> is).</li>
<li>Stores your list of known shows, characters, or series titles for automatic folder organization when 'Separate Folders by Name/Title' is enabled.</li>
<li><b>Format:</b>
<ul>
<li>Each line is an entry.</li>
<li><b>Simple Name:</b> e.g., <code>My Awesome Series</code>. Content matching this will go into a folder named "My Awesome Series".</li>
<li><b>Grouped Aliases:</b> e.g., <code>(Character A, Char A, Alt Name A)</code>. Content matching "Character A", "Char A", OR "Alt Name A" will ALL go into a single folder named "Character A Char A Alt Name A" (after cleaning). All terms in the parentheses become aliases for that folder.</li>
</ul>
</li>
<li><b>Usage:</b> Serves as a fallback for folder naming if a post doesn't match your active 'Filter by Character(s)' input. You can manage simple entries via the UI or edit the file directly for complex aliases. The app reloads it on startup or next use.</li>
</ul>
</li>
<li><b><code>cookies.txt</code> (Optional):</b>
<ul>
<li>If you use the 'Use Cookie' feature and don't provide a direct cookie string or browse to a specific file, the application will look for a file named <code>cookies.txt</code> in its directory.</li>
<li><b>Format:</b> Must be in Netscape cookie file format.</li>
<li><b>Usage:</b> Allows the downloader to use your browser's login session for accessing content that might be behind a login on Kemono/Coomer.</li>
</ul>
</li>
</ul>
<h3>First-Time User Tour</h3>
<ul>
<li>On the first launch (or if reset), a welcome tour dialog appears, guiding you through the main features. You can skip it or choose to "Never show this tour again."</li>
</ul>
<p><em>Many UI elements also have tooltips that appear when you hover your mouse over them, providing quick hints.</em></p>
</body></html>
"""
steps = [
(page1_title, page1_content),
(page2_title, page2_content),
(page3_title, page3_content),
(page4_title, page4_content),
(page5_title, page5_content),
(page6_title, page6_content),
(page7_title, page7_content),
(page8_title, page8_content),
]
guide_dialog = HelpGuideDialog(steps, self)
guide_dialog.exec_()
def prompt_add_character(self, character_name): def prompt_add_character(self, character_name):
global KNOWN_NAMES global KNOWN_NAMES
reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The name '{character_name}' was encountered or used as a filter.\nIt's not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The name '{character_name}' was encountered or used as a filter.\nIt's not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
@@ -4350,17 +3819,6 @@ class DownloaderApp(QWidget):
if __name__ == '__main__': if __name__ == '__main__':
import traceback import traceback
import sys # Ensure sys is imported here if not already
import os # Ensure os is imported here
import time # For timestamping errors
def log_error_to_file(exc_info_tuple):
# Log file will be next to the .exe or main.py
log_file_path = os.path.join(os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(__file__), "critical_error_log.txt")
with open(log_file_path, "a", encoding="utf-8") as f:
f.write(f"Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
traceback.print_exception(*exc_info_tuple, file=f)
f.write("-" * 80 + "\n\n")
try: try:
qt_app = QApplication(sys.argv) qt_app = QApplication(sys.argv)
if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS