12 Commits

Author SHA1 Message Date
Yuvi9587
66e52cfd78 Commit 2025-05-25 12:27:15 +05:30
Yuvi9587
e665fd3cde Commit 2025-05-25 11:38:38 +05:30
Yuvi9587
fc94f4c691 Commit 2025-05-24 22:55:23 +05:30
Yuvi9587
78e2012f04 Commit 2025-05-24 13:30:06 +05:30
Yuvi9587
3fe9dbacc6 Commit 2025-05-24 13:15:08 +05:30
Yuvi9587
004dea06e0 Commit 2025-05-24 16:22:47 +05:30
Yuvi9587
8994a69c34 Add files via upload 2025-05-24 10:36:15 +05:30
Yuvi9587
f4a692673e main.py 2025-05-24 10:35:46 +05:30
Yuvi9587
4cb5f14ef6 Delete Known.txt 2025-05-23 21:01:05 +05:30
Yuvi9587
a596c4f350 Update main.py 2025-05-23 20:59:35 +05:30
Yuvi9587
e091c60d29 Commit 2025-05-23 20:23:36 +05:30
Yuvi9587
d2ea026a41 Commit 2025-05-23 19:11:52 +05:30
8 changed files with 713 additions and 118 deletions

BIN
Kemono.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

BIN
assets/discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
assets/instagram.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -31,6 +31,7 @@ from io import BytesIO
STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name"
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_POSTS = "posts"
@@ -276,7 +277,7 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
return 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():
if cancellation_event and cancellation_event.is_set(): # type: ignore
logger(" Fetch cancelled before request.")
raise RuntimeError("Fetch operation cancelled by user.")
@@ -284,7 +285,7 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(" Post fetching paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger(" Post fetching cancelled while paused.")
logger(" Post fetching cancelled while paused.") # type: ignore
raise RuntimeError("Fetch operation cancelled by user.")
time.sleep(0.5)
logger(" Post fetching resumed.")
@@ -379,21 +380,37 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
page_size = 50
if is_creator_feed_for_manga:
logger(" Manga Mode: Fetching all posts to reverse order (oldest posts processed first)...")
logger(" Manga Mode: Fetching posts to sort by date (oldest processed first)...")
all_posts_for_manga_mode = []
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:
if pause_event and pause_event.is_set():
logger(" Manga mode post fetching paused...") # type: ignor
logger(" Manga mode post fetching paused...") # type: ignore
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled while paused.")
logger(" Manga mode post fetching cancelled while paused.") # type: ignore
break
time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled.")
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:
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):
@@ -401,7 +418,11 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
break
if not posts_batch_manga:
logger("✅ Reached end of posts (Manga Mode fetch all).")
break
if start_page and not end_page and current_page_num_manga < start_page: # Started on a page with no posts
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)
current_offset_manga += page_size # Increment by page_size for the next API call's 'o' parameter
time.sleep(0.6)
@@ -420,7 +441,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
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)...")
# ... (rest of sorting and yielding logic for manga mode remains the same) ...
def sort_key_tuple(post):
published_date_str = post.get('published')
added_date_str = post.get('added')
@@ -584,7 +605,8 @@ class PostProcessorWorker:
selected_cookie_file=None, # Added missing parameter
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_global_file_counter_ref=None, # New parameter for global numbering
): # type: ignore
self.post = post_data
self.download_root = download_root
self.known_names = known_names
@@ -630,6 +652,7 @@ class PostProcessorWorker:
self.selected_cookie_file = selected_cookie_file # Store selected cookie file path
self.app_base_dir = app_base_dir # Store app base dir
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
if self.compress_images and Image is None:
@@ -667,6 +690,7 @@ class PostProcessorWorker:
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
was_original_name_kept_flag = False
manga_global_file_counter_ref = None # Placeholder, will be passed from process()
final_filename_saved_for_return = ""
def _get_current_character_filters(self):
@@ -677,7 +701,8 @@ class PostProcessorWorker:
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
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
final_filename_saved_for_return = ""
retry_later_details = None # For storing info if retryable failure
@@ -739,6 +764,19 @@ 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}")
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}.")
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:
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)
@@ -1429,6 +1467,14 @@ class PostProcessorWorker:
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(
self._download_single_file,
file_info_to_dl,
@@ -1436,8 +1482,9 @@ class PostProcessorWorker:
headers,
post_id,
self.skip_current_file_flag,
post_title=post_title, # Keyword argument
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,
post_title=post_title,
manga_date_file_counter_ref=manga_date_counter_to_pass,
manga_global_file_counter_ref=manga_global_counter_to_pass,
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
))
@@ -1505,6 +1552,9 @@ class DownloadThread(QThread):
selected_cookie_file=None, # New parameter for selected cookie file
app_base_dir=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__()
self.api_url_input = api_url_input
@@ -1553,6 +1603,7 @@ class DownloadThread(QThread):
self.cookie_text = cookie_text # Store cookie text
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_global_file_counter_ref = manga_global_file_counter_ref # Store for global numbering
if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
self.compress_images = False
@@ -1591,8 +1642,8 @@ class DownloadThread(QThread):
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
if self.use_subfolders:
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[0]["name"])
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"):
series_folder_name = clean_folder_name(self.filter_character_list_objects_initial[0]["name"])
series_scan_dir = os.path.join(series_scan_dir, series_folder_name)
elif self.service and self.user_id:
creator_based_folder_name = clean_folder_name(self.user_id)
@@ -1605,9 +1656,16 @@ class DownloadThread(QThread):
for filename_to_check in filenames_in_dir:
base_name_no_ext = os.path.splitext(filename_to_check)[0]
match = re.match(r"(\d{3,})", base_name_no_ext)
if match: highest_num = max(highest_num, int(match.group(1)))
if match: highest_num = max(highest_num, int(match.group(1))) # Corrected indentation
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]}.")
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()
try:
worker_signals_obj.progress_signal.connect(self.progress_signal)
@@ -1674,6 +1732,7 @@ class DownloadThread(QThread):
selected_cookie_file=self.selected_cookie_file, # Pass selected cookie file
app_base_dir=self.app_base_dir, # Pass app_base_dir
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
manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref
)

718
main.py
View File

@@ -16,7 +16,8 @@ from concurrent.futures import ThreadPoolExecutor, CancelledError, Future
from PyQt5.QtGui import (
QIcon,
QIntValidator
QIntValidator,
QDesktopServices
)
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
@@ -26,7 +27,7 @@ from PyQt5.QtWidgets import (
QFrame,
QAbstractButton
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer, QSettings, QStandardPaths, QCoreApplication
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer, QSettings, QStandardPaths, QCoreApplication, QUrl, QSize
from urllib.parse import urlparse
try:
@@ -53,20 +54,33 @@ try:
CHAR_SCOPE_FILES, # Ensure this is imported
CHAR_SCOPE_BOTH,
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.")
except ImportError as e:
print(f"--- IMPORT ERROR ---")
print(f"Failed to import from 'downloader_utils.py': {e}")
print(f"--- Check downloader_utils.py for syntax errors or missing dependencies. ---")
KNOWN_NAMES = []
PostProcessorSignals = QObject
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
def clean_folder_name(n): return str(n)
def extract_post_info(u): return None, None, None
def download_from_api(*a, **k): yield []
SKIP_SCOPE_FILES = "files"
SKIP_SCOPE_FILES = "files" # type: ignore
SKIP_SCOPE_POSTS = "posts"
SKIP_SCOPE_BOTH = "both"
CHAR_SCOPE_TITLE = "title"
@@ -74,6 +88,7 @@ except ImportError as e:
CHAR_SCOPE_BOTH = "both"
CHAR_SCOPE_COMMENTS = "comments"
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:
print(f"--- UNEXPECTED IMPORT ERROR ---")
@@ -100,6 +115,7 @@ MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name"
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"
ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
@@ -107,7 +123,6 @@ USE_COOKIE_KEY = "useCookieV1" # New setting key
COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text
CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1"
# Custom dialog result constants for ConfirmAddAllDialog
CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -214,9 +229,148 @@ class ConfirmAddAllDialog(QDialog):
super().exec_()
# If user accepted but selected nothing, treat it as skipping addition
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 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):
"""A single step/page in the tour."""
def __init__(self, title_text, content_text, parent=None):
@@ -703,9 +857,8 @@ class DownloaderApp(QWidget):
print(f" Known.txt will be loaded/saved at: {self.config_file}")
self.load_known_names_from_util()
self.setWindowTitle("Kemono Downloader v3.5.0")
self.setWindowTitle("Kemono Downloader v4.0.0")
# self.load_known_names_from_util() # This call is premature and causes the error.
self.setStyleSheet(self.get_dark_theme())
self.init_ui()
@@ -725,8 +878,8 @@ class DownloaderApp(QWidget):
def _get_tooltip_for_character_input(self):
return (
"Names, comma-separated.\n"
"- Individual names: `Tifa`, `Aerith`\n"
"- Group for separate folders: `(Vivi, Ulti, Uta)` -> creates separate Known.txt entries & folders for Vivi, Ulti, Uta.\n"
"- Individual names: `Tifa`, `Aerith` (separate folders, separate Known.txt entries).\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 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."
)
@@ -838,11 +991,18 @@ class DownloaderApp(QWidget):
group_content_str = part_str[1:-1].strip()
aliases_in_group = [alias.strip() for alias in group_content_str.split(',') if alias.strip()]
if aliases_in_group:
# Create separate entries for each item in a non-tilde group
for alias_item in aliases_in_group:
parsed_character_filter_objects.append({"name": alias_item, "is_group": False, "aliases": [alias_item]})
# For (A, B, C) type groups:
# Create a single filter object for a shared folder in the current download.
# Mark with a special flag to handle Known.txt addition differently.
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:
parsed_character_filter_objects.append({"name": part_str, "is_group": False, "aliases": [part_str]}) # Standard single entry
parsed_character_filter_objects.append({"name": part_str, "is_group": False, "aliases": [part_str], "components_are_distinct_for_known_txt": False}) # Standard single entry
return parsed_character_filter_objects
def _process_worker_queue(self):
@@ -1363,8 +1523,18 @@ class DownloaderApp(QWidget):
self.new_char_input.returnPressed.connect(self.add_char_button.click)
self.delete_char_button.clicked.connect(self.delete_selected_character)
char_manage_layout.addWidget(self.new_char_input, 2)
char_manage_layout.addWidget(self.add_char_button, 1)
char_manage_layout.addWidget(self.delete_char_button, 1)
char_manage_layout.addWidget(self.add_char_button, 0)
# 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.addStretch(0)
@@ -1892,10 +2062,17 @@ class DownloaderApp(QWidget):
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
manga_on = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
enable_character_filter_related_widgets = file_download_mode_active and (subfolders_on or manga_on)
# Determine if character filter section should be active (visible and enabled)
# 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:
self.character_input.setEnabled(enable_character_filter_related_widgets)
@@ -1905,7 +2082,9 @@ class DownloaderApp(QWidget):
if self.char_filter_scope_toggle_button:
self.char_filter_scope_toggle_button.setEnabled(enable_character_filter_related_widgets)
self.update_ui_for_subfolders(subfolders_on)
# Call update_ui_for_subfolders to correctly set the "Subfolder per Post" checkbox state
# 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_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
@@ -2125,16 +2304,53 @@ class DownloaderApp(QWidget):
def _handle_ui_add_new_character(self):
"""Handles adding a new character from the UI input field."""
name_from_ui_input = self.new_char_input.text().strip()
successfully_added_any = False
if not name_from_ui_input:
QMessageBox.warning(self, "Input Error", "Name cannot be empty.")
return
# For UI additions, it's always a simple, non-group entry.
# The special ( ) and ( )~ parsing is for the "Filter by Character(s)" field.
self.add_new_character(name_to_add=name_from_ui_input,
is_group_to_add=False,
aliases_to_add=[name_from_ui_input],
suppress_similarity_prompt=False) # UI adds one by one, so prompt is fine
if name_from_ui_input.startswith("(") and name_from_ui_input.endswith(")~"):
# Format: (Name1, Name2)~ -> Group "Name1 Name2" with aliases Name1, Name2
content = name_from_ui_input[1:-2].strip() # Remove ( and )~
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,
aliases_to_add=[name_from_ui_input],
suppress_similarity_prompt=False):
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):
global KNOWN_NAMES, clean_folder_name
@@ -2208,7 +2424,6 @@ 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 ""
self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list{log_msg_suffix}.")
self.new_char_input.clear()
self.save_known_names()
return True
@@ -2260,27 +2475,17 @@ class DownloaderApp(QWidget):
if self.custom_folder_input: self.custom_folder_input.clear()
def update_ui_for_subfolders(self, checked):
def update_ui_for_subfolders(self, separate_folders_by_name_title_checked: bool):
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()
can_enable_subfolder_per_post_checkbox = not is_only_links and not is_only_archives
if self.use_subfolder_per_post_checkbox:
self.use_subfolder_per_post_checkbox.setEnabled(not is_only_links and not is_only_archives)
self.use_subfolder_per_post_checkbox.setEnabled(can_enable_subfolder_per_post_checkbox)
if hasattr(self, 'use_cookie_checkbox'):
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)
if not can_enable_subfolder_per_post_checkbox:
self.use_subfolder_per_post_checkbox.setChecked(False)
self.update_custom_folder_visibility()
@@ -2318,12 +2523,12 @@ class DownloaderApp(QWidget):
_, _, post_id = extract_post_info(url_text)
is_creator_feed = not post_id if url_text else False
manga_mode_active = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
enable_page_range = is_creator_feed and not manga_mode_active
# Manga mode no longer directly dictates page range enabled state.
# Page range is enabled if it's a creator feed.
enable_page_range = is_creator_feed
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)
if widget: widget.setEnabled(enable_page_range) # Enable/disable based on whether it's a creator feed
if not enable_page_range:
if self.start_page_input: self.start_page_input.clear()
@@ -2356,6 +2561,18 @@ class DownloaderApp(QWidget):
" Downloads as: \"001.jpg\", \"002.jpg\".\n\n"
"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:
self.manga_rename_toggle_button.setText("Name: Date Based")
self.manga_rename_toggle_button.setToolTip(
@@ -2382,8 +2599,10 @@ class DownloaderApp(QWidget):
if current_style == STYLE_POST_TITLE: # Title -> Original
new_style = STYLE_ORIGINAL_NAME
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
elif current_style == STYLE_DATE_BASED: # Date -> Title
elif current_style == STYLE_DATE_BASED: # Date Based -> Title
new_style = STYLE_POST_TITLE
else:
self.log_signal.emit(f"⚠️ Unknown current manga filename style: {current_style}. Resetting to default ('{STYLE_POST_TITLE}').")
@@ -2416,24 +2635,20 @@ class DownloaderApp(QWidget):
if self.manga_rename_toggle_button:
self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode))
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()
# Always update page range enabled state, as it depends on URL type, not directly manga mode.
self.update_page_range_enabled_state()
file_download_mode_active = not (self.radio_only_links and self.radio_only_links.isChecked())
subfolders_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
enable_char_filter_widgets = file_download_mode_active and (subfolders_on or manga_mode_effectively_on)
# Character filter widgets should be enabled if it's a file download mode
enable_char_filter_widgets = file_download_mode_active and not (self.radio_only_archives and self.radio_only_archives.isChecked())
if self.character_input:
self.character_input.setEnabled(enable_char_filter_widgets)
if not enable_char_filter_widgets: self.character_input.clear()
if self.char_filter_scope_toggle_button:
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
@@ -2448,7 +2663,7 @@ class DownloaderApp(QWidget):
if self.use_multithreading_checkbox.isChecked():
try:
num_threads_val = int(text)
if num_threads_val > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads_val} Threads)")
if num_threads_val > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads_val} Threads)") # type: ignore
else: self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)")
except ValueError:
self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)")
@@ -2474,10 +2689,12 @@ class DownloaderApp(QWidget):
if not hasattr(self, 'manga_mode_checkbox') or not hasattr(self, 'use_multithreading_checkbox'):
return # UI elements not ready
manga_on = self.manga_mode_checkbox.isChecked()
is_date_style = (self.manga_filename_style == STYLE_DATE_BASED)
if manga_on and is_date_style:
manga_on = self.manga_mode_checkbox.isChecked() # type: ignore
is_sequential_style_requiring_single_thread = (
self.manga_filename_style == STYLE_DATE_BASED or
self.manga_filename_style == STYLE_POST_TITLE_GLOBAL_NUMBERING
)
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():
self.log_signal.emit(" Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.")
@@ -2618,16 +2835,42 @@ class DownloaderApp(QWidget):
start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip()
start_page, end_page = None, None
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:
if start_page_str: start_page = int(start_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 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.")
except ValueError as e: QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}"); return
elif manga_mode:
start_page, end_page = None, None
# If it's a creator feed, and manga mode is on, and both page fields were filled, show warning
if manga_mode and start_page and end_page:
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
raw_character_filters_text = self.character_input.text().strip() # Get current text
@@ -2688,13 +2931,28 @@ class DownloaderApp(QWidget):
elif isinstance(dialog_result, list): # User chose to add selected items
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.")
for filter_obj_to_add in dialog_result: # dialog_result is the list of selected filter_obj
self.add_new_character(
name_to_add=filter_obj_to_add["name"],
is_group_to_add=filter_obj_to_add["is_group"],
aliases_to_add=filter_obj_to_add["aliases"],
suppress_similarity_prompt=True # Suppress for batch adding
)
for filter_obj_to_add in dialog_result: # dialog_result is the list of selected filter_obj from ConfirmAddAllDialog
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(
name_to_add=filter_obj_to_add["name"],
is_group_to_add=filter_obj_to_add["is_group"],
aliases_to_add=filter_obj_to_add["aliases"],
suppress_similarity_prompt=True # Suppress for batch adding
)
else: # Empty list means user selected "Add Selected" but had nothing checked (dialog handles this by returning SKIP_ADDING)
self.log_signal.emit(" User confirmed adding, but no names were selected in the dialog. No new names added to Known.txt.")
elif dialog_result == CONFIRM_ADD_ALL_SKIP_ADDING:
@@ -2747,17 +3005,27 @@ class DownloaderApp(QWidget):
self.retryable_failed_files_info.clear() # Clear previous retryable failures before new session
manga_date_file_counter_ref_for_thread = None
if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not extract_links_only:
manga_date_file_counter_ref_for_thread = None
# Initialization for STYLE_DATE_BASED (scanning existing files) happens in DownloadThread.run
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.")
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_file_threads_per_worker = 1 # Default to 1 for all cases initially
if post_id_from_url:
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))
else:
else: # This is the outer else block
if manga_mode and self.manga_filename_style == STYLE_DATE_BASED:
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
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
@@ -2773,13 +3041,15 @@ class DownloaderApp(QWidget):
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" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker} concurrent file(s)")
if is_creator_feed:
if manga_mode: log_messages.append(" Page Range: All (Manga Mode - Oldest Posts Processed First)")
else:
pr_log = "All"
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()
log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}")
# Logging for page range (applies if is_creator_feed is true)
pr_log = "All"
if start_page or end_page: # Construct pr_log if start_page or end_page have values
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'}")
if not extract_links_only:
@@ -2820,8 +3090,9 @@ class DownloaderApp(QWidget):
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)}")
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 and not post_id_from_url:
log_messages.append(f" Threading: Single-threaded (posts) - Enforced by Manga Date Mode")
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:
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 {enforced_by_style}")
should_use_multithreading_for_posts = False # Ensure this reflects the forced state
else:
log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading_for_posts else 'Single-threaded (posts)'}")
@@ -2875,6 +3146,7 @@ class DownloaderApp(QWidget):
'allow_multipart_download': allow_multipart,
'cookie_text': cookie_text_from_input, # Pass cookie text
'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
'use_cookie': use_cookie_from_checkbox, # Pass cookie setting
}
@@ -2896,7 +3168,8 @@ class DownloaderApp(QWidget):
'skip_words_list', 'skip_words_scope', 'char_filter_scope',
'show_external_links', 'extract_links_only', 'num_file_threads_for_worker',
'start_page', 'end_page', 'target_post_id_from_initial_url',
'manga_date_file_counter_ref', # Ensure this is passed for single thread mode
'manga_date_file_counter_ref',
'manga_global_file_counter_ref', # Pass new counter for single thread mode
'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
]
@@ -3093,7 +3366,8 @@ class DownloaderApp(QWidget):
'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
'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 = {
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list',
@@ -3101,6 +3375,8 @@ class DownloaderApp(QWidget):
'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
}
# 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 :
self.log_signal.emit(f" High thread count ({num_post_workers}) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES} parts.")
@@ -3719,6 +3995,261 @@ class DownloaderApp(QWidget):
self._update_manga_filename_style_button_text()
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):
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)
@@ -3819,6 +4350,17 @@ class DownloaderApp(QWidget):
if __name__ == '__main__':
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:
qt_app = QApplication(sys.argv)
if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS

View File

@@ -1,4 +1,4 @@
<h1 align="center">Kemono Downloader v3.5.0</h1>
<h1 align="center">Kemono Downloader v4.0.0</h1>
<div align="center">
<img src="https://github.com/Yuvi9587/Kemono-Downloader/blob/main/Read.png" alt="Kemono Downloader"/>
@@ -11,7 +11,7 @@ Built with **PyQt5**, this tool is ideal for users who want deep filtering, cust
---
## What's New in v3.5.0?
## What's New in v4.0.0?
Version 3.5.0 focuses on enhancing access to content and providing even smarter organization:
@@ -39,11 +39,6 @@ The `Known.txt` file and the "Filter by Character(s)" input field work together
- **Simple Entries:**
- A line like `My Awesome Series` or `Nami`.
- **Behavior:** Content matching this term will be saved into a folder named "My Awesome Series" or "Nami" respectively (if "Separate Folders" is enabled).
- **Grouped Alias Entries (for a single character/entity):**
- Format: `(PrimaryFolderName, alias1, alias2, ...)`
- **Example:** `(Boa Hancock, Boa, Hancock)`
- **Behavior:** Content matching "Boa Hancock", "Boa", OR "Hancock" will be saved into a folder named "Boa Hancock". The first item in the parentheses is the primary folder name; all items are matching aliases.
- **Example:** `(Power, powwr, pwr, Blood Devil)` creates a folder "Power" for content matching any of those terms.
**2. "Filter by Character(s)" UI Input Field:**
@@ -58,7 +53,7 @@ This field allows for dynamic filtering for the current download session and pro
- Input: `(Boa, Hancock)~`
- Meaning: "Boa" and "Hancock" are different names/aliases for the *same character*. The names are listed within parentheses separated by commas (e.g., `name1, alias1, alias2`), and the entire group is followed by a `~` symbol. This is useful when a creator uses different names for the same character.
- Session Behavior: Filters for "Boa" OR "Hancock". If "Separate Folders" is on, creates a single folder named "Boa Hancock".
- `Known.txt` Addition: If this group is new and selected for addition, it's added to `Known.txt` as a grouped alias entry, typically `(Boa Hancock, Boa, Hancock)`. The first name in the `Known.txt` entry (e.g., "Boa Hancock") becomes the primary folder name.
- `Known.txt` Addition: If this group is new and selected for addition, it's added to `Known.txt` as a grouped alias entry, typically `(Boa Hancock)`. The first name in the `Known.txt` entry (e.g., "Boa Hancock") becomes the primary folder name.
- **Combined Folder for Distinct Characters (using `(...)` syntax):**
- Input: `(Vivi, Uta)`
@@ -78,7 +73,7 @@ This field allows for dynamic filtering for the current download session and pro
- **Direct Management:** You can add simple entries directly to `Known.txt` using the list and "Add" button in the UI's `Known.txt` management section. For creating or modifying complex grouped alias entries directly in the file, or for bulk edits, click the "Open Known.txt" button. The application reloads `Known.txt` on startup or before a download process begins.
---
## What's in v3.4.0? (Previous Update)
## What's in v3.5.0? (Previous Update)
This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience:
### Enhanced Manga/Comic Mode
@@ -135,7 +130,7 @@ This version brings significant enhancements to manga/comic downloading, filteri
### Updated Onboarding Tour
- Improved guide for new users, covering v3.4.0 features and existing core functions.
- Improved guide for new users, covering v4.0.0 features and existing core functions.
---
@@ -172,7 +167,7 @@ This version brings significant enhancements to manga/comic downloading, filteri
- Flexible input for current session and for adding to `Known.txt`.
- Examples:
- `Nami` (simple character)
- `(Boa ~ Hancock)` (aliases for one character, session folder "Boa Hancock", adds `(Boa Hancock, Boa, Hancock)` to `Known.txt`)
- `(Boa Hancock)~` (aliases for one character, session folder "Boa Hancock", adds `(Boa Hancock)` to `Known.txt`)
- `(Vivi, Uta)` (distinct characters, session folder "Vivi Uta", adds `Vivi` and `Uta` separately to `Known.txt`)
- See "Advanced `Known.txt` and Character Filtering" for full details.
- **Filter Scopes:**