5 Commits

Author SHA1 Message Date
Yuvi63771
67faea0992 Commit 2025-11-16 21:05:36 +05:30
Yuvi63771
be03f914ef Commit 2025-11-12 20:10:13 +05:30
Yuvi63771
ec9900b90f Commit 2025-11-09 19:41:21 +05:30
Yuvi63771
55ebfdb980 Tree 2025-11-08 20:14:19 +05:30
Yuvi63771
4a93b721e2 Commit 2025-11-04 09:13:54 +05:30
10 changed files with 1560 additions and 255 deletions

View File

@@ -159,8 +159,6 @@ def download_from_api(
if cancellation_event and cancellation_event.is_set():
logger(" Download_from_api cancelled at start.")
return
# The code that defined api_domain was moved from here to the top of the function
if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
@@ -312,6 +310,8 @@ def download_from_api(
current_offset = (start_page - 1) * page_size
current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
# --- START OF MODIFIED BLOCK ---
while True:
if pause_event and pause_event.is_set():
logger(" Post fetching loop paused...")
@@ -321,18 +321,23 @@ def download_from_api(
break
time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
if cancellation_event and cancellation_event.is_set():
logger(" Post fetching loop cancelled.")
break
if target_post_id and processed_target_post_flag:
break
if not target_post_id and end_page and current_page_num > end_page:
logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
break
try:
posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance(posts_batch, list):
logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
# 1. Fetch the raw batch of posts
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance(raw_posts_batch, list):
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
break
except RuntimeError as e:
if "cancelled by user" in str(e).lower():
@@ -344,14 +349,9 @@ def download_from_api(
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback.print_exc()
break
if processed_post_ids:
original_count = len(posts_batch)
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(posts_batch)
if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
if not posts_batch:
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
if not raw_posts_batch:
if target_post_id and not processed_target_post_flag:
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
elif not target_post_id:
@@ -359,20 +359,45 @@ def download_from_api(
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
else:
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
break
break # This break is now correct.
# 3. Filter the batch against processed IDs
posts_batch_to_yield = raw_posts_batch
original_count = len(raw_posts_batch)
if processed_post_ids:
posts_batch_to_yield = [post for post in raw_posts_batch if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(posts_batch_to_yield)
if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
# 4. Process the *filtered* batch
if target_post_id and not processed_target_post_flag:
matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
# Still searching for a specific post
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
if matching_post:
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
yield [matching_post]
processed_target_post_flag = True
elif not target_post_id:
yield posts_batch
# Downloading a creator feed
if posts_batch_to_yield:
# We found new posts on this page, yield them
yield posts_batch_to_yield
elif original_count > 0:
# We found 0 new posts, but the page *did* have posts (they were just skipped).
# Log this and continue to the next page.
logger(f" No new posts found on page {current_page_num}. Checking next page...")
# If original_count was 0, the `if not raw_posts_batch:` check
# already caught it and broke the loop.
if processed_target_post_flag:
break
current_offset += page_size
current_page_num += 1
time.sleep(0.6)
# --- END OF MODIFIED BLOCK ---
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")

View File

@@ -145,7 +145,9 @@ def download_and_decrypt_mega_file(info, download_path, logger_func, progress_ca
logger_func(f" [Mega] Download for '{file_name}' cancelled before starting.")
return
if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:
# i tried to make the mega download multipart for big file but it didnt work you can try if you can fix this to make it multipart replace "if true" with this "if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:" to activate multipart
if True:
logger_func(f" [Mega] Downloading '{file_name}' (Single Stream)...")
try:
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=0)

View File

@@ -6,7 +6,7 @@ from packaging.version import parse as parse_version
from PyQt5.QtCore import QThread, pyqtSignal
# Constants for the updater
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi63771/Kemono-Downloader/releases/latest"
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi9587/Kemono-Downloader/releases/latest"
EXE_NAME = "Kemono.Downloader.exe"
class UpdateChecker(QThread):

View File

@@ -134,7 +134,9 @@ class SimpCityDownloadThread(QThread):
with self.counter_lock: self.total_skip_count += 1
return
self.progress_signal.emit(f" -> Downloading (Image): '{filename}'...")
response = session.get(job['url'], stream=True, timeout=90, headers={'Referer': self.start_url})
# --- START MODIFICATION ---
response = session.get(job['url'], stream=True, timeout=180, headers={'Referer': self.start_url})
# --- END MODIFICATION ---
response.raise_for_status()
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
@@ -227,7 +229,9 @@ class SimpCityDownloadThread(QThread):
else:
self.progress_signal.emit(f" -> Downloading: '{filename}'...")
headers = file_data.get('headers', {'Referer': source_url})
response = session.get(file_data.get('url'), stream=True, timeout=90, headers=headers)
# --- START MODIFICATION ---
response = session.get(file_data.get('url'), stream=True, timeout=180, headers=headers)
# --- END MODIFICATION ---
response.raise_for_status()
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
@@ -298,16 +302,30 @@ class SimpCityDownloadThread(QThread):
try:
page_title, jobs_on_page, final_url = fetch_single_simpcity_page(page_url, self._log_interceptor, cookies=self.cookies)
# --- START: MODIFIED REDIRECT LOGIC ---
if final_url != page_url:
self.progress_signal.emit(f" -> Redirect detected from {page_url} to {final_url}")
try:
req_page_match = re.search(r'/page-(\d+)', page_url)
final_page_match = re.search(r'/page-(\d+)', final_url)
if req_page_match and final_page_match and int(final_page_match.group(1)) < int(req_page_match.group(1)):
self.progress_signal.emit(" -> Redirected to an earlier page. Reached end of thread.")
end_of_thread = True
if req_page_match:
req_page_num = int(req_page_match.group(1))
# Scenario 1: Redirect to an earlier page (e.g., page-11 -> page-10)
if final_page_match and int(final_page_match.group(1)) < req_page_num:
self.progress_signal.emit(f" -> Redirected to an earlier page ({final_page_match.group(0)}). Reached end of thread.")
end_of_thread = True
# Scenario 2: Redirect to base URL (e.g., page-11 -> /)
# We check req_page_num > 1 because page-1 often redirects to base URL, which is normal.
elif not final_page_match and req_page_num > 1:
self.progress_signal.emit(f" -> Redirected to base thread URL. Reached end of thread.")
end_of_thread = True
except (ValueError, TypeError):
pass
pass # Ignore parsing errors
# --- END: MODIFIED REDIRECT LOGIC ---
if end_of_thread:
page_fetch_successful = True; break
@@ -316,25 +334,40 @@ class SimpCityDownloadThread(QThread):
self.progress_signal.emit(f" -> Page {page_counter} is invalid or has no title. Reached end of thread.")
end_of_thread = True
elif not jobs_on_page:
self.progress_signal.emit(f" -> Page {page_counter} has no content. Reached end of thread.")
end_of_thread = True
else:
new_jobs = [job for job in jobs_on_page if job.get('url') not in self.processed_job_urls]
if not new_jobs and page_counter > 1:
self.progress_signal.emit(f" -> Page {page_counter} contains no new content. Reached end of thread.")
end_of_thread = True
else:
enriched_jobs = self._get_enriched_jobs(new_jobs)
for job in enriched_jobs:
self.processed_job_urls.add(job.get('url'))
if job['type'] == 'image': self.image_queue.put(job)
else: self.service_queue.put(job)
if not enriched_jobs and not new_jobs:
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
end_of_thread = True
else:
for job in enriched_jobs:
self.processed_job_urls.add(job.get('url'))
if job['type'] == 'image': self.image_queue.put(job)
else: self.service_queue.put(job)
page_fetch_successful = True; break
except requests.exceptions.HTTPError as e:
if e.response.status_code in [403, 404]: end_of_thread = True; break
elif e.response.status_code == 429: time.sleep(5 * (retries + 2)); retries += 1
else: end_of_thread = True; break
if e.response.status_code in [403, 404]:
self.progress_signal.emit(f" -> Page {page_counter} returned {e.response.status_code}. Reached end of thread.")
end_of_thread = True; break
elif e.response.status_code == 429:
self.progress_signal.emit(f" -> Rate limited (429). Waiting...")
time.sleep(5 * (retries + 2)); retries += 1
else:
self.progress_signal.emit(f" -> HTTP Error {e.response.status_code} on page {page_counter}. Stopping crawl.")
end_of_thread = True; break
except Exception as e:
self.progress_signal.emit(f" Stopping crawl due to error on page {page_counter}: {e}"); end_of_thread = True; break
if not page_fetch_successful and not end_of_thread: end_of_thread = True
if not page_fetch_successful and not end_of_thread:
self.progress_signal.emit(f" -> Failed to fetch page {page_counter} after {MAX_RETRIES} attempts. Stopping crawl.")
end_of_thread = True
if not end_of_thread: page_counter += 1
except Exception as e:
self.progress_signal.emit(f"❌ A critical error occurred during the main fetch phase: {e}")

View File

@@ -22,6 +22,8 @@ from ..main_window import get_app_icon_object
from ...core.api_client import download_from_api
from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
from ...utils.resolution import get_dark_theme
# --- IMPORT THE NEW DIALOG ---
from .UpdateCheckDialog import UpdateCheckDialog
class PostsFetcherThread (QThread ):
@@ -138,7 +140,7 @@ class EmptyPopupDialog (QDialog ):
SCOPE_CREATORS ="Creators"
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
def __init__ (self ,user_data_path ,parent_app_ref ,parent =None ):
super ().__init__ (parent )
self.parent_app = parent_app_ref
@@ -146,13 +148,18 @@ class EmptyPopupDialog (QDialog ):
self.setMinimumSize(int(400 * scale_factor), int(300 * scale_factor))
self.current_scope_mode = self.SCOPE_CREATORS
self .app_base_dir =app_base_dir
self.user_data_path = user_data_path
app_icon =get_app_icon_object ()
if app_icon and not app_icon .isNull ():
self .setWindowIcon (app_icon )
# --- MODIFIED: Store a list of profiles now ---
self.update_profiles_list = None
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
self.update_profile_data = None
self.update_creator_name = None
self .selected_creators_for_queue =[]
self .globally_selected_creators ={}
self .fetched_posts_data ={}
@@ -321,29 +328,34 @@ class EmptyPopupDialog (QDialog ):
pass
def _handle_update_check(self):
"""Opens a dialog to select a creator profile and loads it for an update session."""
appdata_dir = os.path.join(self.app_base_dir, "appdata")
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
"""
--- MODIFIED FUNCTION ---
Opens the new UpdateCheckDialog instead of a QFileDialog.
If a profile is selected, it sets the dialog's result properties
and accepts the dialog, just like the old file dialog logic did.
"""
# --- NEW BEHAVIOR ---
# Pass the app_base_dir and a reference to the main app (for translations/theme)
dialog = UpdateCheckDialog(self.user_data_path, self.parent_app, self)
if not os.path.isdir(profiles_dir):
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
return
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
if filepath:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'creator_url' not in data or 'processed_post_ids' not in data:
raise ValueError("Invalid profile format.")
self.update_profile_data = data
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
self.accept() # Close the dialog and signal success
except Exception as e:
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
if dialog.exec_() == QDialog.Accepted:
# --- MODIFIED: Get a list of profiles now ---
selected_profiles = dialog.get_selected_profiles()
if selected_profiles:
try:
# --- MODIFIED: Store the list ---
self.update_profiles_list = selected_profiles
# --- Set deprecated single-profile fields for backward compatibility (optional) ---
# --- This helps if other parts of the main window still expect one profile ---
self.update_profile_data = selected_profiles[0]['data']
self.update_creator_name = selected_profiles[0]['name']
self.accept() # Close EmptyPopupDialog and signal success to main_window
except Exception as e:
QMessageBox.critical(self, "Error Loading Profile",
f"Could not process the selected profile data:\n\n{e}")
# --- END OF NEW BEHAVIOR ---
def _handle_fetch_posts_click (self ):
selected_creators =list (self .globally_selected_creators .values ())
@@ -981,9 +993,14 @@ class EmptyPopupDialog (QDialog ):
def _handle_posts_close_view (self ):
self .right_pane_widget .hide ()
self .main_splitter .setSizes ([self .width (),0 ])
self .posts_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
# --- MODIFIED: Added check before disconnect ---
if hasattr (self ,'_handle_post_item_check_changed'):
self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
try:
self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
except TypeError:
pass # Already disconnected
self .posts_search_input .setVisible (False )
self .posts_search_input .clear ()
self .globally_selected_post_ids .clear ()

View File

@@ -7,7 +7,8 @@ import sys
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
QTabWidget, QWidget, QFileDialog # Added QFileDialog
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
@@ -111,7 +112,7 @@ class CountdownMessageBox(QDialog):
class FutureSettingsDialog(QDialog):
"""
A dialog for managing application-wide settings like theme, language,
and display options, with an organized layout.
and display options, using a tabbed layout.
"""
def __init__(self, parent_app_ref, parent=None):
super().__init__(parent)
@@ -124,8 +125,9 @@ class FutureSettingsDialog(QDialog):
self.setWindowIcon(app_icon)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0
base_min_w, base_min_h = 420, 520 # Increased height for new options
# Use a more balanced aspect ratio
scale_factor = screen_height / 1000.0
base_min_w, base_min_h = 480, 420 # Wider, less tall
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
@@ -137,67 +139,105 @@ class FutureSettingsDialog(QDialog):
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
# --- Create Tab Widget ---
self.tab_widget = QTabWidget()
main_layout.addWidget(self.tab_widget)
self.interface_group_box = QGroupBox()
interface_layout = QGridLayout(self.interface_group_box)
# --- Create Tabs ---
self.display_tab = QWidget()
self.downloads_tab = QWidget()
self.updates_tab = QWidget()
# Add tabs to the widget
self.tab_widget.addTab(self.display_tab, "Display")
self.tab_widget.addTab(self.downloads_tab, "Downloads")
self.tab_widget.addTab(self.updates_tab, "Updates")
# --- Populate Display Tab ---
display_tab_layout = QVBoxLayout(self.display_tab)
self.display_group_box = QGroupBox()
display_layout = QGridLayout(self.display_group_box)
self.theme_label = QLabel()
self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme)
interface_layout.addWidget(self.theme_label, 0, 0)
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
display_layout.addWidget(self.theme_label, 0, 0)
display_layout.addWidget(self.theme_toggle_button, 0, 1)
self.ui_scale_label = QLabel()
self.ui_scale_combo_box = QComboBox()
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
interface_layout.addWidget(self.ui_scale_label, 1, 0)
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
display_layout.addWidget(self.ui_scale_label, 1, 0)
display_layout.addWidget(self.ui_scale_combo_box, 1, 1)
self.language_label = QLabel()
self.language_combo_box = QComboBox()
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
interface_layout.addWidget(self.language_label, 2, 0)
interface_layout.addWidget(self.language_combo_box, 2, 1)
display_layout.addWidget(self.language_label, 2, 0)
display_layout.addWidget(self.language_combo_box, 2, 1)
main_layout.addWidget(self.interface_group_box)
self.download_window_group_box = QGroupBox()
download_window_layout = QGridLayout(self.download_window_group_box)
self.window_size_label = QLabel()
self.resolution_combo_box = QComboBox()
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
download_window_layout.addWidget(self.window_size_label, 0, 0)
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
display_layout.addWidget(self.window_size_label, 3, 0)
display_layout.addWidget(self.resolution_combo_box, 3, 1)
display_tab_layout.addWidget(self.display_group_box)
display_tab_layout.addStretch(1) # Push content to the top
# --- Populate Downloads Tab ---
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
self.download_settings_group_box = QGroupBox()
download_settings_layout = QGridLayout(self.download_settings_group_box)
self.default_path_label = QLabel()
self.save_path_button = QPushButton()
self.save_path_button.clicked.connect(self._save_settings)
download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1)
self.date_prefix_format_label = QLabel()
self.date_prefix_format_input = QLineEdit()
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
download_window_layout.addWidget(self.date_prefix_format_label, 2, 0)
download_window_layout.addWidget(self.date_prefix_format_input, 2, 1)
download_settings_layout.addWidget(self.default_path_label, 0, 0)
download_settings_layout.addWidget(self.save_path_button, 0, 1)
self.post_download_action_label = QLabel()
self.post_download_action_combo = QComboBox()
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
download_window_layout.addWidget(self.post_download_action_label, 3, 0)
download_window_layout.addWidget(self.post_download_action_combo, 3, 1)
download_settings_layout.addWidget(self.post_download_action_label, 1, 0)
download_settings_layout.addWidget(self.post_download_action_combo, 1, 1)
self.date_prefix_format_label = QLabel()
self.date_prefix_format_input = QLineEdit()
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
download_settings_layout.addWidget(self.date_prefix_format_label, 2, 0)
download_settings_layout.addWidget(self.date_prefix_format_input, 2, 1)
self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
download_window_layout.addWidget(self.save_creator_json_checkbox, 4, 0, 1, 2)
download_settings_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2)
self.fetch_first_checkbox = QCheckBox()
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
download_window_layout.addWidget(self.fetch_first_checkbox, 5, 0, 1, 2)
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box)
# --- START: Add new Load/Save buttons ---
settings_file_layout = QHBoxLayout()
self.load_settings_button = QPushButton()
self.save_settings_button = QPushButton()
settings_file_layout.addWidget(self.load_settings_button)
settings_file_layout.addWidget(self.save_settings_button)
settings_file_layout.addStretch(1)
# Add this new layout to the grid
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2) # Row 5, span 2 cols
# Connect signals
self.load_settings_button.clicked.connect(self._handle_load_settings)
self.save_settings_button.clicked.connect(self._handle_save_settings)
# --- END: Add new Load/Save buttons ---
downloads_tab_layout.addWidget(self.download_settings_group_box)
downloads_tab_layout.addStretch(1) # Push content to the top
# --- Populate Updates Tab ---
updates_tab_layout = QVBoxLayout(self.updates_tab)
self.update_group_box = QGroupBox()
update_layout = QGridLayout(self.update_group_box)
self.version_label = QLabel()
@@ -207,29 +247,39 @@ class FutureSettingsDialog(QDialog):
update_layout.addWidget(self.version_label, 0, 0)
update_layout.addWidget(self.update_status_label, 0, 1)
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
main_layout.addWidget(self.update_group_box)
main_layout.addStretch(1)
updates_tab_layout.addWidget(self.update_group_box)
updates_tab_layout.addStretch(1) # Push content to the top
# --- OK Button (outside tabs) ---
button_layout = QHBoxLayout()
button_layout.addStretch(1)
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept)
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
button_layout.addWidget(self.ok_button)
main_layout.addLayout(button_layout)
def _retranslate_ui(self):
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
# --- Tab Titles ---
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display"))
self.tab_widget.setTabText(1, self._tr("settings_tab_downloads", "Downloads"))
self.tab_widget.setTabText(2, self._tr("settings_tab_updates", "Updates"))
# --- Display Tab ---
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
self.theme_label.setText(self._tr("theme_label", "Theme:"))
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
self.language_label.setText(self._tr("language_label", "Language:"))
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
# --- Downloads Tab ---
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
# Update placeholder to include {post}
self.date_prefix_format_input.setPlaceholderText(self._tr("date_prefix_format_placeholder", "e.g., YYYY-MM-DD {post} {postid}"))
# Add the tooltip to explain usage
self.date_prefix_format_input.setToolTip(self._tr(
"date_prefix_format_tooltip",
"Create a custom folder name using placeholders:\n"
@@ -238,25 +288,32 @@ class FutureSettingsDialog(QDialog):
"{postid}: for the post's unique ID\n\n"
"Example: {post} [{postid}] [YYYY-MM-DD]"
))
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
self._update_theme_toggle_button_text()
self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK"))
# --- START: Add new button text ---
self.load_settings_button.setText(self._tr("load_settings_button", "Load Settings..."))
self.load_settings_button.setToolTip(self._tr("load_settings_tooltip", "Load all download settings from a .json file."))
self.save_settings_button.setText(self._tr("save_settings_button", "Save Settings..."))
self.save_settings_button.setToolTip(self._tr("save_settings_tooltip", "Save all current download settings to a .json file."))
# --- END: Add new button text ---
# --- Updates Tab ---
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
current_version = self.parent_app.windowTitle().split(' v')[-1]
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
# --- General ---
self._update_theme_toggle_button_text()
self.ok_button.setText(self._tr("ok_button", "OK"))
# --- Load Data ---
self._populate_display_combo_boxes()
self._populate_language_combo_box()
self._populate_post_download_action_combo()
@@ -331,7 +388,38 @@ class FutureSettingsDialog(QDialog):
def _apply_theme(self):
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
base_stylesheet = get_dark_theme(scale)
# --- START: Tab Styling Fix ---
tab_stylesheet = """
QTabWidget::pane {
border-top: 1px solid #444;
margin-top: -1px; /* Overlap with tab bar */
background-color: #2D2D2D;
}
QTabBar::tab {
background-color: #3D3D3D;
color: #BBBBBB;
border: 1px solid #444;
border-bottom: none; /* No bottom border for tabs */
padding: 6px 12px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
QTabBar::tab:selected {
background-color: #2D2D2D; /* Same as pane background */
color: #EEEEEE;
border-bottom: 1px solid #2D2D2D; /* Hides the pane top border */
margin-bottom: -1px; /* Pulls tab down to cover pane border */
}
QTabBar::tab:!selected:hover {
background-color: #4A4A4A;
}
"""
# --- END: Tab Styling Fix ---
self.setStyleSheet(base_stylesheet + tab_stylesheet)
else:
self.setStyleSheet("")
@@ -490,4 +578,98 @@ class FutureSettingsDialog(QDialog):
if path_saved or cookie_saved or token_saved:
QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
else:
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
# --- START: New functions for Save/Load ---
def _get_settings_dir(self):
"""Helper to get a consistent directory for saving/loading profiles."""
if hasattr(self.parent_app, 'user_data_path'):
# We use 'user_data_path' which should point to 'appdata'
settings_dir = os.path.join(self.parent_app.user_data_path, "settings_profiles")
os.makedirs(settings_dir, exist_ok=True)
return settings_dir
# Fallback if user_data_path isn't available
return QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation)
def _handle_save_settings(self):
"""
Calls the main app to get all settings, then saves them to a user-chosen JSON file.
"""
if not hasattr(self.parent_app, '_get_current_ui_settings_as_dict'):
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
self._tr("settings_missing_save_func_error", "Parent application is missing the required save function."))
return
settings_dir = self._get_settings_dir()
filepath, _ = QFileDialog.getSaveFileName(
self,
self._tr("save_settings_dialog_title", "Save Settings Profile"),
settings_dir,
self._tr("json_files_filter", "JSON Files (*.json)")
)
if filepath:
if not filepath.endswith('.json'):
filepath += '.json'
try:
# Get all settings from the main window
settings_data = self.parent_app._get_current_ui_settings_as_dict()
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(settings_data, f, indent=2)
QMessageBox.information(self,
self._tr("save_settings_success_title", "Settings Saved"),
self._tr("save_settings_success_msg", "Settings successfully saved to:\n{filename}")
.format(filename=os.path.basename(filepath)))
except Exception as e:
QMessageBox.critical(self,
self._tr("save_settings_error_title", "Error Saving Settings"),
str(e))
def _handle_load_settings(self):
"""
Lets the user pick a JSON file, loads it, and applies the settings to the main app.
"""
if not hasattr(self.parent_app, '_load_ui_from_settings_dict') or \
not hasattr(self.parent_app, '_update_all_ui_states'):
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
self._tr("settings_missing_load_func_error", "Parent application is missing the required load functions."))
return
settings_dir = self._get_settings_dir()
filepath, _ = QFileDialog.getOpenFileName(
self,
self._tr("load_settings_dialog_title", "Load Settings Profile"),
settings_dir,
self._tr("json_files_filter", "JSON Files (*.json)")
)
if filepath:
try:
with open(filepath, 'r', encoding='utf-8') as f:
settings_data = json.load(f)
if not isinstance(settings_data, dict):
raise ValueError(self._tr("settings_invalid_json_error", "File is not a valid settings dictionary."))
# Apply all settings to the main window
self.parent_app._load_ui_from_settings_dict(settings_data)
# Refresh the main window UI to show changes
self.parent_app._update_all_ui_states()
QMessageBox.information(self,
self._tr("load_settings_success_title", "Settings Loaded"),
self._tr("load_settings_success_msg", "Successfully loaded settings from:\n{filename}")
.format(filename=os.path.basename(filepath)))
# Close the settings dialog after loading
self.accept()
except Exception as e:
QMessageBox.critical(self,
self._tr("load_settings_error_title", "Error Loading Settings"),
str(e))
# --- END: New functions for Save/Load ---

View File

@@ -6,7 +6,6 @@ from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
@@ -26,7 +25,8 @@ class TourStepWidget(QWidget):
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
# Use a consistent color for titles regardless of theme
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB; padding-bottom: 15px;")
layout.addWidget(title_label)
scroll_area = QScrollArea()
@@ -41,17 +41,456 @@ class TourStepWidget(QWidget):
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True)
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
# Set a base line-height and color
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.5;")
scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1)
class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide with a navigation list."""
def __init__(self, steps_data, parent_app, parent=None):
super().__init__(parent)
self.steps_data = steps_data
self.parent_app = parent_app
super().__init__(parent_app)
self.parent_app = parent_app # This is the main_window instance
self.steps_data = [
("Welcome!",
"""
<p style='font-size: 12pt;'>Welcome to the Kemono Downloader! This guide will walk you through the key features to get you started.</p>
<h3 style='color: #E0E0E0;'>Wide Range of Support</h3>
<p>This application provides full, direct download support for several popular sites, including:</p>
<ul>
<li>Kemono</li>
<li>Coomer</li>
<li>Bunkr</li>
<li>Erome</li>
<li>Saint2.su</li>
<li>nhentai.net/</li>
<li>fap-nation.org/</li>
<li>Discord</li>
<li>allporncomic.com</li>
<li>allporncomic.com</li>
<li>hentai2read.com</li>
<li>mangadex.org</li>
<li>Simpcity</li>
<li>gelbooru.com</li>
<li>Toonily.com</li>
</ul>
<h3 style='color: #E0E0E0;'>Powerful Batch Mode</h3>
<p>Save time by downloading hundreds of URLs at once. Simply type <b>nhentai.net</b> or <b>saint2.su</b> into the URL bar. The app will look for a <b>nhentai.txt</b> or <b>saint2.su.txt</b> file in your 'appdata' folder and process all the URLs inside it.</p>
<h3 style='color: #E0E0E0;'>Advanced Discord Support</h3>
<p>Go beyond simple file downloading. The app can connect directly to the Discord API to:</p>
<ul>
<li>Download all files from a specific channel.</li>
<li>Save an entire channel's message history as a fully formatted PDF.</li>
</ul>
"""),
("Advanced Filtering",
"""
<p>Control exactly what content you download, from broad categories to specific keywords.</p>
<h3 style='color: #E0E0E0;'>Content Type Filters</h3>
<p>These radio buttons let you select the main <i>type</i> of content you want:</p>
<ul>
<li><b>All:</b> Downloads everything (default).</li>
<li><b>Images/GIFs:</b> Only downloads static images and GIFs.</li>
<li><b>Videos:</b> Only downloads video files (MP4, WEBM, MOV, etc.).</li>
<li><b>Only Archives:</b> Exclusively downloads .zip and .rar files.</li>
<li><b>Only Links:</b> Extracts external links (Mega, Google Drive) from post descriptions instead of downloading.</li>
<li><b>Only Audio:</b> Only downloads audio files (MP3, WAV, etc.).</li>
<li><b>More:</b> Opens a dialog to download post descriptions or comments as text/PDF.</li>
</ul>
<h3 style='color: #E0E0E0;'>Character Filtering</h3>
<p>The <b>"Filter by Character(s)"</b> input is your most powerful tool for targeting content.</p>
<ul>
<li><b>Basic Use:</b> Enter names, separated by commas (e.g., <code>Tifa, Aerith</code>). This will create folders for "Tifa" and "Aerith" and download posts matching those names.</li>
<li><b>Grouped Aliases:</b> Use parentheses to group aliases for a single character (e.g., <code>(Tifa, Lockhart)</code>). This still creates a "Tifa" folder, but it will also match posts that just say "Lockhart".</li>
</ul>
<p>The <b>"Filter: [Scope]"</b> button changes <i>what</i> is scanned:</p>
<ul>
<li><b>Filter: Title (Default):</b> Scans only the post's main title.</li>
<li><b>Filter: Files:</b> Scans the <i>filenames</i> within the post.</li>
<li><b>Filter: Both:</b> Scans both the title and the filenames.</li>
<li><b>Filter: Comments (Beta):</b> Scans the post's comment section for the keywords.</li>
</ul>
<h3 style='color: #E0E0E0;'>Skip Filters (Avoid Content)</h3>
<p>The <b>"Skip with Words"</b> input lets you avoid content you don't want.</p>
<p>The <b>"Scope: [Scope]"</b> button changes <i>how</i> it skips:</p>
<ul>
<li><b>Scope: Posts (Default):</b> Skips the <i>entire post</i> if the post's title contains a skip word (e.g., <code>WIP, sketch</code>).</li>
<li><b>Scope: Files:</b> Scans and skips <i>individual files</i> if their filename contains a skip word.</li>
<li><b>Scope: Both:</b> Skips the post if the title matches, and if not, still checks individual files.</li>
</ul>
<h3 style='color: #E0E0E0;'>Other Content Options</h3>
<ul>
<li><b>Skip .zip:</b> A quick toggle to ignore all archive files.</li>
<li><b>Download Thumbnails Only:</b> Downloads the small preview image instead of the full-resolution file.</li>
<li><b>Scan Content for Images:</b> Scans the post's text description for <code>&lt;img&gt;</code> tags. Useful for embedded images not in the post's attachment list.</li>
<li><b>Keep Duplicates:</b> By default, the app skips files with identical content (hash). Check this to open a dialog and configure it to keep duplicate files.</li>
</ul>
<h3 style='color: #E0E0E0;'>Filename Control</h3>
<p>The <b>"Remove Words from name"</b> input cleans up filenames. Any text you enter here (comma-separated) will be removed from the final saved filename (e.g., <code>patreon, exclusive</code>).</p>
"""),
("Folder Management (Known.txt)",
"""
<p>This feature, enabled by the <b>"Separate Folders by Known.txt"</b> checkbox, automatically sorts your downloads. It's designed mainly for <b>Kemono</b>, where creators often tag posts with character names in the title.</p>
<p>When you download from a creator, this feature checks each <b>post title</b> against your `Known.txt` list. If a name matches, a folder is created for that name, and all posts from that creator mentioning the name will be <b>grouped together</b> in that single folder.</p>
<h3 style='color: #E0E0E0;'>Folder Naming Priority</h3>
<p>When "Separate Folders" is checked, the app uses this priority to name folders:</p>
<ol>
<li><b>Character Filter:</b> If you use the <b>"Filter by Character(s)"</b> input (e.g., <code>Tifa</code>), that name is <b>always</b> used as the folder name. This overrides all other rules.</li>
<li><b>Known.txt (Post Title):</b> If no filter is used, it checks the <b>post's title</b> for a name in `Known.txt`. (This is the most common use case).</li>
<li><b>Known.txt (Filename):</b> If the title doesn't match, it checks all <b>filenames</b> in the post for a match in `Known.txt`.</li>
<li><b>Fallback:</b> If no match is found, it creates a generic folder from the post's title.</li>
</ol>
<h3 style='color: #E0E0E0;'>Editing Your Known.txt File</h3>
<p>You can manage this list using the panel on the right of the main window or by clicking <b>"Open Known.txt"</b> to edit it directly. There are two formats:</p>
<ul>
<li><b>Simple Name:</b><br>
<code>Tifa</code><br>
This creates a folder named "Tifa" and matches posts/files named "Tifa".
</li>
<br>
<li><b>Grouped Aliases:</b><br>
<code>(Tifa, Lockhart)</code><br>
This is the most powerful format. It creates a folder named <b>"Tifa Lockhart"</b> and will match posts/files that contain either "Tifa" <i>or</i> "Lockhart". This is perfect for characters with multiple names.
</li>
</ul>
<h3 style='color: #E0E0E0;'>Important Note:</h3>
<p>This automatic sorting <b>only works if the creator includes the character names or keywords in the post title</b> (or filename). If they don't, the app has no way of knowing how to sort the post, and it will fall back to a generic folder name.</p>
"""),
("Renaming Mode",
"""
<p>This mode is designed for downloading comics, manga, or any multi-file post where you need files to be in a specific, sequential order. When active, it downloads posts from <b>oldest to newest</b>.</p>
<p>Activate it by checking the <b>"Renaming Mode"</b> checkbox. This reveals a new button: <b>"Name: [Style]"</b>. Clicking this button cycles through all available naming conventions.</p>
<h3 style='color: #E0E0E0;'>Available Naming Styles</h3>
<ul>
<li><b>Post Title:</b> (Default) Files are named after the post's title, with a number for multi-file posts (e.g., <code>My Comic Page_1.jpg</code>, <code>My Comic Page_2.jpg</code>).</li>
<li><b>Date + Original:</b> Prepends the post's date to the original filename (e.g., <code>2025-11-16_original_file_name.jpg</code>).</li>
<li><b>Date + Title:</b> Prepends the date to the post title (e.g., <code>2025-11-16_My Comic Page_1.jpg</code>).</li>
<li><b>Post ID:</b> Names files using the post's unique ID and the file index (e.g., <code>9876543_0.jpg</code>, <code>9876543_1.jpg</code>).</li>
<li><b>Date Based:</b> Renames all files to a simple, sequential number (e.g., <code>001.jpg</code>, <code>002.jpg</code>). You can add a prefix in the text box that appears (e.g., "Chapter 1 " to get <code>Chapter 1 001.jpg</code>).
<br><b style='color: #f0ad4e;'>Note: This mode disables multithreading to guarantee correct file order.</b></li>
<li><b>Title + G.Num (Global Numbering):</b> Names files by title, but with a *global* counter (e.g., <code>Post A_001.jpg</code>, <code>Post B_002.jpg</code>).
<br><b style='color: #f0ad4e;'>Note: This mode also disables multithreading.</b></li>
<li><b>Custom:</b> Lets you design your own filename using a format string. A <b>"..."</b> button will appear to open the custom format dialog.</li>
</ul>
<h3 style='color: #E0E0E0;'>Custom Format Placeholders</h3>
<p>When using the "Custom" style, you can use these placeholders (click the buttons in the dialog to add them):</p>
<ul>
<li><code>{id}</code> - The unique ID of the post.</li>
<li><code>{creator_name}</code> - The creator's name.</li>
<li><code>{service}</code> - The service (e.g., Patreon, Pixiv Fanbox, etc).</li>
<li><code>{title}</code> - The title of the post.</li>
<li><code>{added}</code> - Date the post was added.</li>
<li><code>{published}</code> - Date the post was published.</li>
<li><code>{edited}</code> - Date the post was last edited.</li>
<li><code>{name}</code> - The original name of the file.</li>
</ul>
<p>You can also set a custom <b>Date Format</b> (e.g., <code>YYYY-MM-DD</code>) that will apply to the {added}, {published}, and {edited} placeholders.</p>
"""),
("Batch Downloading",
"""
<p>This feature allows you to download hundreds of URLs from a text file, which is much faster than queuing them one by one.</p>
<h3 style='color: #E0E0E0;'>How It Works (Step-by-Step)</h3>
<ol>
<li><b>Find your 'appdata' folder:</b> This is in the same directory as the downloader's <code>.exe</code> file.</li>
<li><b>Create a .txt file:</b> Inside the 'appdata' folder, create a text file for the site you want to batch from. The name must be exact. (eg.. nhentai.txt, hentai2read.txt, etc.. )</li>
<li><b>Add URLs:</b> Open the <code>.txt</code> file and paste one download URL on each line. Save the file.</li>
<li><b>Start the Batch:</b> In the downloader's main URL bar, type the <b>site's domain name</b> (e.g., <code>nhentai.net</code>) and click "Start Download".</li>
</ol>
<p>The app will automatically find your text file, read all the URLs, and download them sequentially.</p>
<h3 style='color: #E0E0E0;'>Supported Sites and Filenames</h3>
<p>The <code>.txt</code> file name must match the site you are triggering:</p>
<ul>
<li><b>To trigger, type:</b> <code>allporncomic.com</code><br>
<b>Text file name:</b> <code>allporncomic.txt</code></li>
<li><b>To trigger, type:</b> <code>nhentai.net</code><br>
<b>Text file name:</b> <code>nhentai.txt</code></li>
<li><b>To trigger, type:</b> <code>fap-nation.com</code> or <code>fap-nation.org</code><br>
<b>Text file name:</b> <code>fap-nation.txt</code></li>
<li><b>To trigger, type:</b> <code>saint2.su</code><br>
<b>Text file name:</b> <code>saint2.su.txt</code></li>
<li><b>To trigger, type:</b> <code>hentai2read.com</code><br>
<b>Text file name:</b> <code>hentai2read.txt</code></li>
<li><b>To trigger, type:</b> <code>rule34video.com</code><br>
<b>Text file name:</b> <code>rule34video.txt</code></li>
</ul>
"""),
("Special Modes: Text & Links",
"""
<p>These two modes completely change the downloader's function from downloading files to extracting information.</p>
<h3 style='color: #E0E0E0;'>🔗 Only Links Mode</h3>
<p>When you select this, the app <b>stops downloading files</b>. Instead, it scans the post's description for any external URLs (like Mega, Google Drive, Dropbox, etc.) and lists them in the main log.</p>
<p>This mode also reveals a new set of tools above the log:</p>
<ul>
<li><b>Search Bar:</b> Lets you filter the extracted links by keyword (e.g., "mega", "part 1").</li>
<li><b>Export Links Button:</b> Opens a dialog to save all the found links to a <code>.txt</code> file.</li>
<li><b>Download Button:</b> Opens a new dialog that lets you selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.</li>
</ul>
<h3 style='color: #E0E0E0;'>📄 More (Text Export Mode)</h3>
<p>This mode downloads the <b>text content</b> from posts instead of the files. When you select it, a dialog appears asking for more details:</p>
<ul>
<li><b>Scope:</b>
<ul>
<li><b>Description/Content:</b> Saves the text from the post's main body.</li>
<li><b>Comments:</b> Fetches and saves all the comments from the post.</li>
</ul>
</li>
<li><b>Export as:</b> You can choose to save the text as a <b>PDF</b>, <b>DOCX</b>, or <b>TXT</b> file.</li>
<li><b>Single PDF:</b> (Only available for PDF format) This powerful option stops the app from saving individual PDF files. Instead, it collects the text from <i>all</i> matching posts, sorts them by date, and compiles them into <b>one single, large PDF file</b> at the end of the download session.</li>
</ul>
"""),
("Special Commands",
"""
<p>You can add special commands to the <b>"Filter by Character(s)"</b> input field to change download behavior for a single task. Commands are keywords wrapped in square brackets <code>[]</code>.</p>
<p><b>Example:</b> <code>Tifa, (Cloud, Zack) [ao] [sfp-10]</code></p>
<h3 style='color: #E0E0E0;'>Filter Commands (in "Filter by Character(s)" input)</h3>
<ul>
<li><b><code>[ao]</code> (Archive Only Priority)</b><br>
This command prioritizes archives.
<ul>
<li>If a post contains <b>only images/videos</b>, it will download them normally.</li>
<li>If a post contains <b>both archives AND images/videos</b>, this command tells the app to <b>only download the archives</b> and skip the other files for that post.</li>
</ul>
</li>
<br>
<li><b><code>[sfp-N]</code> (Subfolder Per Post Threshold)</b><br>
This is an override for when "Subfolder per Post" is <b>OFF</b> (and "Separate Folders by Known.txt" is <b>ON</b>).<br>
For example, if you set <code>[sfp-10]</code>:
<ul>
<li>Posts with <b>less than 10 files</b> will download normally into the main folder (e.g., <code>/ArtistName/</code>).</li>
<li>When a post with <b>10 or more files</b> is found, this command will <b>force a subfolder to be created for that one post</b> (e.g., <code>/ArtistName/Comic_Title/</code>) to keep its files grouped together.</li>
</ul>
</li>
<br>
<li><b><code>[unknown]</code> (Handle Unknown)</b><br>
Changes how sorting works when "Separate Folders by Known.txt" is on. If a post title doesn't match any name in your <code>Known.txt</code> list, this command will create a folder using the post's title instead of a generic fallback folder.
</li>
<br>
<li><b><code>[.domain]</code> (Domain Override)</b><br>
An advanced command. For example, <code>[.st]</code> forces the app to download from <code>coomer.st</code>, and <code>[.cr]</code> forces it to download from <code>kemono.cr</code>. This can be useful if one domain is blocked or slow.
</li>
</ul>
<h3 style='color: #E0E0E0;'>Skip Command (in "Skip with Words" input)</h3>
<p>This command is different and goes into the <b>"Skip with Words"</b> input field, along with any other skip words.</p>
<ul>
<li><b><code>[N]</code> (Skip File by Size)</b><br>
This command skips any file that is <b>smaller</b> than <code>N</code> megabytes (MB).<br>
<b>Example:</b> Entering <code>WIP, sketch, [200]</code> into the "Skip with Words" input will skip files with "WIP" or "sketch" in their name, AND it will also skip any file smaller than 200MB.
</li>
</ul>
"""),
("Cloud Storage & Direct Links",
"""
<p>The downloader has built-in support for popular cloud storage and direct-link sites. You can use this in two main ways.</p>
<h3 style='color: #E0E0E0;'>Method 1: Direct URL Download</h3>
<p>You can paste a direct link from these services into the main URL bar and hit "Start Download" just like a Kemono link.</p>
<ul>
<li><b>Pixeldrain:</b> Supports single files (<code>/u/...</code>), albums (<code>/l/...</code>), and folders (<code>/d/...</code>).</li>
<li><b>Mega.nz:</b> Supports both single file links (<code>/file/...</code>) and folder links (<code>/folder/...</code>).</li>
<li><b>Gofile.io:</b> Supports folder links (<code>/d/...</code>).</li>
<li><b>Google Drive:</b> Supports shared folder links.</li>
<li><b>Dropbox:</b> Supports shared <code>.zip</code> file links. It will automatically download, extract, and delete the <code>.zip</code> file.</li>
</ul>
<h3 style='color: #E0E0E0;'>Method 2: "Only Links" Mode Downloader</h3>
<p>This is a two-step process for handling posts that have many cloud links in their description.</p>
<ol>
<li><b>Step 1: Extract Links</b><br>
Select the <b>"🔗 Only Links"</b> radio button and run a download on a creator or post page. The app will scan all posts and list the external links (Mega, GDrive, etc.) it finds in the log.
</li>
<br>
<li><b>Step 2: Download Links</b><br>
After extraction, a <b>"Download"</b> button (next to "Export Links") will become active. This opens a new window where you can selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.
</li>
</ol>
<h3 style='color: #E0E0E0;'>Note: SimpCity Integration</h3>
<p>SimpCity support relies heavily on this feature. When you download from a SimpCity thread, the app <b>automatically</b> scans the page for links to services like <b>Pixeldrain, Bunkr, Saint2, Mega, and Gofile</b> and then downloads them just as if you had put in those links directly. You can control which of these services are downloaded from the checkboxes in the "SimpCity Settings" section of the main window.</p>
"""),
("Creator Selection & Updates",
"""
<p>Clicking the <b>🎨 button</b> (next to the URL bar) opens the <b>Creator Selection</b> dialog. This is your control for managing creators you've already downloaded from.</p>
<h3 style='color: #E0E0E0;'>Main List & Searching</h3>
<p>The main list shows all creators from your <code>creators.json</code> file. You can:</p>
<ul>
<li><b>Search:</b> The top search bar filters your creators by name, service, or even a direct URL.</li>
<li><b>Select:</b> Check the boxes next to creators to select them for an action.</li>
</ul>
<h3 style='color: #E0E0E0;'>Action Buttons</h3>
<p><b>Check for Updates</b></p>
<p>This button opens a new window, "Check for Updates," which lists all your <b>Creator Profiles</b> (the <code>.json</code> files saved in your <code>appdata/creator_profiles</code> folder). These profiles are created automatically when you download a full creator page.</p>
<p>From this dialog, you can check multiple creators at once. The app will scan all of them and then show a final "Start Download" button on the main window to download <i>only</i> the new posts, using the same settings you used for each creator last time.</p>
<p><b>Add Selected</b></p>
<p>This is the simplest action. It takes all the creators you've checked, puts their names in the main URL bar, and closes the dialog. This is a quick way to add multiple creators to the download queue for a download.</p>
<p><b>Fetch Posts</b></p>
<p>This is a powerful tool for finding specific posts. When you click it:</p>
<ol>
<li>The dialog expands, and a new panel appears on the right.</li>
<li>The app fetches <i>every single post</i> from all the creators you selected. This may take time.</li>
<li>The right panel fills with a list of all posts, grouped by creator.</li>
<li>You can now search this list and check the boxes next to the <i>individual posts</i> you want.</li>
<li>Clicking <b>"Add Selected Posts to Queue"</b> adds only those specific posts to the download queue.</li>
</ol>
"""),
("⭐ Favorite Mode",
"""
<p>This mode is a powerful feature for downloading directly from your personal <b>Kemono</b> and <b>Coomer</b> favorites lists. It requires you to be logged in on your browser and to provide your cookies to the app.</p>
<p><b style='color: #f0ad4e;'>Important:</b> You <b>must</b> check the <b>"Use Cookie"</b> box and provide a valid cookie for this mode to work. If cookies are missing or invalid, the app will show you a help dialog.</p>
<h3 style='color: #E0E0E0;'>How to Use Favorite Mode</h3>
<ol>
<li>Check the <b>"⭐ Favorite Mode"</b> checkbox on the main window. This will lock the URL bar and show two new buttons.</li>
<li>Click either <b>"🖼️ Favorite Artists"</b> or <b>"📄 Favorite Posts"</b>.</li>
<li>A new dialog will open and begin fetching all your favorites from both Kemono and Coomer at the same time.</li>
<li>Once loaded, you can search, filter, and select the artists or posts you want to download.</li>
<li>Click "Download Selected" to add them to the main download queue and begin processing.</li>
</ol>
<h3 style='color: #E0E0E0;'>Favorite Artists</h3>
<p>The <b>"Favorite Artists"</b> dialog will load your list of followed creators. When you download from here, the app treats it as a full creator download, just as if you had pasted in that artist's URL.</p>
<h3 style='color: #E0E0E0;'>Favorite Posts</h3>
<p>The <b>"Favorite Posts"</b> dialog loads a list of every individual post you have favorited. This dialog has some extra features:</p>
<ul>
<li><b>Creator Name Resolution:</b> It attempts to match the post's creator ID with the names in your <code>creators.json</code> file to show you a recognizable name.</li>
<li><b>Known.txt Matching:</b> It highlights posts by showing <code>[Known - Tifa]</code> in the title if the post title matches an entry in your <code>Known.txt</code> list, helping you find specific content.</li>
<li><b>Grouping:</b> Posts are automatically grouped by creator to keep the list organized.</li>
</ul>
<h3 style='color: #E0E0E0;'>Download Scope (Artist Folders)</h3>
<p>In Favorite Mode, the <b>"Scope: [Location]"</b> button becomes very important. It controls <i>where</i> your favorited items are saved:</p>
<ul>
<li><b>Scope: Selected Location (Default):</b> Downloads all selected items directly into the main "Download Location" folder you have set.</li>
<li><b>Scope: Artist Folders:</b> This automatically creates a new subfolder for each artist inside your main "Download Location" (e.g., <code>/Downloads/ArtistName/</code>). This is the best way to keep your favorites organized.</li>
</ul>
"""),
("File & Download Options",
"""
<p>These checkboxes give you fine-grained control over which files are downloaded and how they are processed.</p>
<h3 style='color: #E0E0E0;'>File Type & Content</h3>
<ul>
<li><b>Skip .zip:</b> A simple toggle. When checked, the downloader will skip all <code>.zip</code> and <code>.rar</code> archive files it finds.</li>
<br>
<li><b>Scan Content for Images:</b> This is a powerful feature for posts where images are embedded in the description (<code>&lt;img&gt;</code> tags) but not listed as attachments. When checked, the app will scan the post's HTML content and try to find and download these embedded images.</li>
</ul>
<h3 style='color: #E0E0E0;'>Image Processing</h3>
<ul>
<li><b>Download Thumbnails Only:</b> Saves bandwidth and time by downloading the small preview/thumbnail version of an image instead of the full-resolution file.</li>
<br>
<li><b>Compress to WebP:</b> If an image is over 1.5MB, this option will automatically convert it to the <code>.webp</code> format during the download, which significantly reduces file size while maintaining high quality.</li>
</ul>
<h3 style='color: #E0E0E0;'>Duplicate Handling</h3>
<ul>
<li><b>Keep Duplicates:</b> By default, the app checks the <i>content</i> (hash) of a file and will not re-download a file it already has. Checking this box opens a dialog with more options:
<ul>
<li><b>Hash (Default):</b> The standard behavior.</li>
<li><b>Keep Everything:</b> Disables all duplicate checks and downloads every file from the API, even if you already have it.</li>
<li><b>Limit:</b> Lets you set a limit (e.g., 2) to how many times a file with the same content can be downloaded.</li>
</ul>
</li>
</ul>
"""),
("Utility & Advanced Options",
"""
<p>These features provide advanced control over your downloads, sessions, and application settings.</p>
<h3 style='color: #E0E0E0;'>Use Cookie</h3>
<p>This is essential for downloading from sites that require a login (like <b>SimpCity</b> or accessing your <b>favorites</b> on Kemono/Coomer). You can either:</p>
<ul>
<li><b>Paste a cookie string:</b> Copy the "cookie" value from your browser's developer tools and paste it into the text field.</li>
<li><b>Use a file:</b> Click the "Browse" button to select a <code>cookies.txt</code> file exported from your browser.</li>
</ul>
<h3 style='color: #E0E0E0;'>Page Range</h3>
<p>When downloading from a creator's main page (not a single post), these "Start" and "End" fields let you limit the download. For example, entering <code>Start: 1</code> and <code>End: 5</code> will only download posts from the first five pages.</p>
<h3 style='color: #E0E0E0;'>Multi-part Download</h3>
<p>Clicking the <b>"Multi-part: OFF"</b> button opens a dialog to enable high-speed downloads for large files. It will split a large file into multiple parts and download them at the same time. You can choose to apply this to videos, archives, or both, and set the minimum file size to trigger it.</p>
<h3 style='color: #E0E0E0;'>Download History</h3>
<p>The <b>"History"</b> button opens a dialog showing two lists:</p>
<ul>
<li><b>Last 3 Files:</b> The last 3 individual files you successfully downloaded.</li>
<li><b>First 3 Posts:</b> The first 3 posts Processed from your *most recent* download session.</li>
</ul>
<h3 style='color: #E0E0E0;'>Settings (Gear Icon)</h3>
<p>The <b>Gear</b> icon ⚙️ opens the main application settings, which is now organized into tabs:</p>
<ul>
<li><b>Display Tab:</b> Change the app's <b>Theme</b> (Light/Dark), <b>Language</b>, <b>UI Scale</b>, and default <b>Window Size</b>.</li>
<li><b>Downloads Tab:</b>
<ul>
<li>Save your current <b>Download Path</b>, <b>Cookie</b>, and <b>Discord Token</b> for future sessions using the "Save Path + Cookie + Token" button.</li>
<li>Set an <b>Action After Download</b> (e.g., Notify, Sleep, Shutdown).</li>
<li>Customize the <b>Post Subfolder Format</b> for when the date prefix is used (e.g., <code>YYYY-MM-DD {post}</code>).</li>
<li>Toggle <b>"Save Creator.json file"</b> (which enables the "Check for Updates" feature).</li>
<li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
</ul>
</li>
<li><b>Updates Tab:</b> Check for and install new application updates.</li>
</ul>
<h3 style='color: #E0E0E0;'>Reset Button</h3>
<p>The <b>"Reset"</b> button (bottom right) is a soft reset. It clears all input fields (except your Download Location), clears the logs, and resets all download options and filters back to their default state. It does <b>not</b> clear your Download History or saved Settings.</p>
""")
]
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
@@ -66,7 +505,38 @@ class HelpGuideDialog(QDialog):
current_theme_style = ""
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
current_theme_style = get_dark_theme(scale)
base_style = get_dark_theme(scale)
list_widget_style = f"""
QListWidget {{
background-color: #2E2E2E;
border: 1px solid #4A4A4A;
border-radius: 4px;
font-size: {int(11 * scale)}pt;
color: #DCDCDC;
}}
QListWidget::item {{
padding: 10px;
border-bottom: 1px solid #4A4A4A;
}}
QListWidget::item:selected {{
background-color: #87CEEB;
color: #1E1E1E;
font-weight: bold;
}}
QListWidget::item:hover:!selected {{
background-color: #3A3A3A;
}}
/* Style for the TourStepWidget content */
TourStepWidget QLabel {{
color: #DCDCDC;
}}
TourStepWidget QScrollArea {{
background-color: transparent;
}}
"""
current_theme_style = base_style + list_widget_style
else:
# Basic light theme fallback
current_theme_style = f"""
@@ -83,29 +553,50 @@ class HelpGuideDialog(QDialog):
}}
QPushButton:hover {{ background-color: #CACACA; }}
QPushButton:pressed {{ background-color: #B0B0B0; }}
QListWidget {{
background-color: #FFFFFF;
border: 1px solid #C0C0C0;
border-radius: 4px;
font-size: {int(11 * scale)}pt;
color: #1E1E1E;
}}
QListWidget::item {{
padding: 10px;
border-bottom: 1px solid #E0E0E0;
}}
QListWidget::item:selected {{
background-color: #0078D7;
color: #FFFFFF;
font-weight: bold;
}}
QListWidget::item:hover:!selected {{
background-color: #F0F0F0;
}}
TourStepWidget QLabel {{
color: #1E1E1E;
}}
TourStepWidget h3 {{
color: #005A9E;
}}
"""
self.setStyleSheet(current_theme_style)
self._init_ui()
if self.parent_app:
self.move(self.parent_app.geometry().center() - self.rect().center())
def _tr(self, key, default_text=""):
"""Helper to get translation based on current app language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _init_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(15, 15, 15, 15)
main_layout.setSpacing(10)
# Title
title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
title_label = QLabel("Kemono Downloader - Feature Guide")
scale = getattr(self.parent_app, 'scale_factor', 1.0)
title_font_size = int(16 * scale)
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;")
# Use a consistent color for the main title
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB;")
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
@@ -115,34 +606,14 @@ class HelpGuideDialog(QDialog):
self.nav_list = QListWidget()
self.nav_list.setFixedWidth(int(220 * scale))
self.nav_list.setStyleSheet(f"""
QListWidget {{
background-color: #2E2E2E;
border: 1px solid #4A4A4A;
border-radius: 4px;
font-size: {int(11 * scale)}pt;
}}
QListWidget::item {{
padding: 10px;
border-bottom: 1px solid #4A4A4A;
}}
QListWidget::item:selected {{
background-color: #87CEEB;
color: #2E2E2E;
font-weight: bold;
}}
""")
# Styles are now set in the __init__ method
content_layout.addWidget(self.nav_list)
self.stacked_widget = QStackedWidget()
content_layout.addWidget(self.stacked_widget)
for title_key, content_key in self.steps_data:
title = self._tr(title_key, title_key)
content = self._tr(content_key, f"Content for {content_key} not found.")
for title, content in self.steps_data:
self.nav_list.addItem(title)
step_widget = TourStepWidget(title, content, scale=scale)
self.stacked_widget.addWidget(step_widget)
@@ -171,13 +642,19 @@ class HelpGuideDialog(QDialog):
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
tooltip_map = {
"help_guide_github_tooltip": "Visit the project on GitHub",
"help_guide_instagram_tooltip": "Follow the developer on Instagram",
"help_guide_discord_tooltip": "Join the official Discord server"
}
for button, tooltip_key, url in [
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi63771/Kemono-Downloader"),
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
]:
button.setIconSize(icon_size)
button.setToolTip(self._tr(tooltip_key))
button.setToolTip(tooltip_map.get(tooltip_key, ""))
button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
button.setStyleSheet("background-color: transparent; border: none;")
button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
@@ -185,7 +662,7 @@ class HelpGuideDialog(QDialog):
footer_layout.addStretch(1)
self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
self.finish_button = QPushButton("Finish")
self.finish_button.clicked.connect(self.accept)
footer_layout.addWidget(self.finish_button)

View File

@@ -0,0 +1,179 @@
# --- Standard Library Imports ---
import json
import os
import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QMessageBox, QAbstractItemView, QLabel
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class UpdateCheckDialog(QDialog):
"""
A dialog that lists all creator .json profiles with checkboxes
and allows the user to select multiple to check for updates.
"""
def __init__(self, user_data_path, parent_app_ref, parent=None):
super().__init__(parent)
self.parent_app = parent_app_ref
self.user_data_path = user_data_path
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
self._init_ui()
self._load_profiles()
self._retranslate_ui()
# Apply theme from parent
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")
def _init_ui(self):
"""Initializes the UI components."""
self.setWindowTitle("Check for Updates")
self.setMinimumSize(400, 450)
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
layout = QVBoxLayout(self)
self.info_label = QLabel("Select creator profiles to check for updates:")
layout.addWidget(self.info_label)
# --- List Widget with Checkboxes ---
self.list_widget = QListWidget()
# No selection mode, we only care about checkboxes
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
layout.addWidget(self.list_widget)
# --- All Buttons in One Horizontal Layout ---
button_layout = QHBoxLayout()
button_layout.setSpacing(6) # small even spacing between all buttons
self.select_all_button = QPushButton("Select All")
self.select_all_button.clicked.connect(self._toggle_all_checkboxes)
self.deselect_all_button = QPushButton("Deselect All")
self.deselect_all_button.clicked.connect(self._toggle_all_checkboxes)
self.close_button = QPushButton("Close")
self.close_button.clicked.connect(self.reject)
self.check_button = QPushButton("Check Selected")
self.check_button.clicked.connect(self.on_check_selected)
self.check_button.setDefault(True)
# Add buttons without a stretch (so no large gap)
button_layout.addWidget(self.select_all_button)
button_layout.addWidget(self.deselect_all_button)
button_layout.addWidget(self.close_button)
button_layout.addWidget(self.check_button)
layout.addLayout(button_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on current app language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Translates the UI elements."""
self.setWindowTitle(self._tr("update_check_dialog_title", "Check for Updates"))
self.info_label.setText(self._tr("update_check_dialog_info_multiple", "Select creator profiles to check for updates:"))
self.select_all_button.setText(self._tr("select_all_button_text", "Select All"))
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
def _load_profiles(self):
"""Loads all .json files from the creator_profiles directory as checkable items."""
appdata_dir = self.user_data_path
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
if not os.path.isdir(profiles_dir):
QMessageBox.warning(self,
self._tr("update_check_dir_not_found_title", "Directory Not Found"),
self._tr("update_check_dir_not_found_msg",
"The creator profiles directory does not exist yet.\n\nPath: {path}")
.format(path=profiles_dir))
return
profiles_found = []
for filename in os.listdir(profiles_dir):
if filename.endswith(".json"):
filepath = os.path.join(profiles_dir, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
# Basic validation to ensure it's a valid profile
if 'creator_url' in data and 'processed_post_ids' in data:
creator_name = os.path.splitext(filename)[0]
profiles_found.append({'name': creator_name, 'data': data})
else:
print(f"Skipping invalid profile: {filename}")
except Exception as e:
print(f"Failed to load profile {filename}: {e}")
profiles_found.sort(key=lambda x: x['name'].lower())
for profile_info in profiles_found:
item = QListWidgetItem(profile_info['name'])
item.setData(Qt.UserRole, profile_info)
# --- Make item checkable ---
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
self.list_widget.addItem(item)
if not profiles_found:
self.list_widget.addItem(self._tr("update_check_no_profiles", "No creator profiles found."))
self.list_widget.setEnabled(False)
self.check_button.setEnabled(False)
self.select_all_button.setEnabled(False)
self.deselect_all_button.setEnabled(False)
def _toggle_all_checkboxes(self):
"""Handles Select All and Deselect All button clicks."""
sender = self.sender()
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(check_state)
def on_check_selected(self):
"""Handles the 'Check Selected' button click."""
self.selected_profiles_list = []
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item.checkState() == Qt.Checked:
profile_info = item.data(Qt.UserRole)
if profile_info:
self.selected_profiles_list.append(profile_info)
if not self.selected_profiles_list:
QMessageBox.warning(self,
self._tr("update_check_no_selection_title", "No Selection"),
self._tr("update_check_no_selection_msg", "Please select at least one creator to check."))
return
self.accept()
def get_selected_profiles(self):
"""Returns the list of profile data selected by the user."""
return self.selected_profiles_list

View File

@@ -149,6 +149,7 @@ class DownloaderApp (QWidget ):
external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
file_progress_signal =pyqtSignal (str ,object )
fetch_only_complete_signal = pyqtSignal(list)
batch_update_check_complete_signal = pyqtSignal(list)
def __init__(self):
@@ -156,6 +157,10 @@ class DownloaderApp (QWidget ):
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
self.active_update_profile = None
self.new_posts_for_update = []
self.active_update_profiles_list = [] # For batch updates
self.fetched_posts_for_batch_update = [] # Stores {'post_data': ..., 'creator_settings': ...}
self.is_ready_to_download_batch_update = False
self.is_finishing = False
self.finish_lock = threading.Lock()
@@ -334,7 +339,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v7.5.2")
self.setWindowTitle("Kemono Downloader v6.7.0")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@@ -775,6 +780,17 @@ class DownloaderApp (QWidget ):
self.cancel_btn.clicked.connect(self.reset_application_state)
return # <-- This 'return' is CRITICAL
elif self.is_ready_to_download_batch_update:
num_posts = len(self.fetched_posts_for_batch_update)
self.download_btn.setText(f"⬇️ Start Download ({num_posts} New Posts)")
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.pause_btn.setEnabled(False)
self.cancel_btn.setText("🗑️ Clear Update")
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self.reset_application_state)
return
if self.active_update_profile and self.new_posts_for_update and not is_download_active:
# State: Update confirmation (new posts found, waiting for user to start)
num_new = len(self.new_posts_for_update)
@@ -1130,6 +1146,7 @@ class DownloaderApp (QWidget ):
self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
self.actual_gui_signals.worker_finished_signal.connect(self._handle_worker_result)
self .actual_gui_signals .file_download_status_signal .connect (lambda status :None )
self.batch_update_check_complete_signal.connect(self._batch_update_check_finished)
self.fetch_only_complete_signal.connect(self._fetch_only_finished)
if hasattr (self ,'character_input'):
@@ -1795,7 +1812,9 @@ class DownloaderApp (QWidget ):
supported_platforms_for_button ={'mega','google drive','dropbox'}
has_supported_links =any (
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
for link_info in self.extracted_links_cache
)
self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links )
@@ -3244,8 +3263,7 @@ class DownloaderApp (QWidget ):
if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue):
is_single_post = True
# --- MODIFIED: Added check for is_discord_url ---
can_enable_manga_checkbox = ((is_creator_feed or is_single_post) or is_favorite_mode_on) and not is_discord_url
can_enable_manga_checkbox = (is_favorite_mode_on or not is_discord_url)
if self .manga_mode_checkbox :
self .manga_mode_checkbox .setEnabled (can_enable_manga_checkbox)
if not can_enable_manga_checkbox and self .manga_mode_checkbox .isChecked ():
@@ -3515,6 +3533,18 @@ class DownloaderApp (QWidget ):
return get_theme_stylesheet(actual_scale)
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
if not is_restore and not is_continuation:
if self.main_log_output: self.main_log_output.clear()
if self.external_log_output: self.external_log_output.clear()
if self.missed_character_log_output: self.missed_character_log_output.clear()
self.missed_key_terms_buffer.clear()
self.already_logged_bold_key_terms.clear()
if self.is_ready_to_download_batch_update:
self._start_download_of_batch_update()
return True
if not direct_api_url:
api_url_text = self.link_input.text().strip().lower()
batch_handlers = {
@@ -3857,30 +3887,6 @@ class DownloaderApp (QWidget ):
num_threads_from_gui = MAX_THREADS
self.thread_count_input.setText(str(MAX_THREADS))
self.log_signal.emit(f"⚠️ User attempted {num_threads_from_gui} threads, capped to {MAX_THREADS}.")
if SOFT_WARNING_THREAD_THRESHOLD < num_threads_from_gui <= MAX_THREADS:
soft_warning_msg_box = QMessageBox(self)
soft_warning_msg_box.setIcon(QMessageBox.Question)
soft_warning_msg_box.setWindowTitle("Thread Count Advisory")
soft_warning_msg_box.setText(
f"You've set the thread count to {num_threads_from_gui}.\n\n"
"While this is within the allowed limit, using a high number of threads (typically above 40-50) can sometimes lead to:\n"
" - Increased errors or failed file downloads.\n"
" - Connection issues with the server.\n"
" - Higher system resource usage.\n\n"
"For most users and connections, 10-30 threads provide a good balance.\n\n"
f"Do you want to proceed with {num_threads_from_gui} threads, or would you like to change the value?"
)
proceed_button = soft_warning_msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
change_button = soft_warning_msg_box.addButton("Change Thread Value", QMessageBox.RejectRole)
soft_warning_msg_box.setDefaultButton(proceed_button)
soft_warning_msg_box.setEscapeButton(change_button)
soft_warning_msg_box.exec_()
if soft_warning_msg_box.clickedButton() == change_button:
self.log_signal.emit(f" User opted to change thread count from {num_threads_from_gui} after advisory.")
self.thread_count_input.setFocus()
self.thread_count_input.selectAll()
return False
raw_skip_words_text = self.skip_words_input.text().strip()
skip_words_parts = [part.strip() for part in raw_skip_words_text.split(',') if part.strip()]
@@ -3987,26 +3993,6 @@ class DownloaderApp (QWidget ):
if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.")
if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.")
if manga_mode and start_page and end_page:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Renaming Mode & Page Range Warning")
msg_box.setText(
"You have enabled <b>Renaming Mode</b> with a sequential naming style (<b>Date Based</b> or <b>Title + G.Num</b>) and also specified a <b>Page Range</b>.\n\n"
"These modes rely on processing all posts from the beginning to create a correct sequence. "
"Using a page range may result in an incomplete or incorrectly ordered download.\n\n"
"It is recommended to use these styles without a page range.\n\n"
"Do you want to proceed anyway?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
msg_box.setDefaultButton(proceed_button)
msg_box.setEscapeButton(cancel_button)
msg_box.exec_()
if msg_box.clickedButton() == cancel_button:
self.log_signal.emit("❌ Download cancelled by user due to Renaming Mode & Page Range warning.")
return False
except ValueError as e:
QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}")
return False
@@ -4411,6 +4397,7 @@ class DownloaderApp (QWidget ):
if self.pause_event: self.pause_event.clear()
self.is_paused = False
return True
def restore_download(self):
"""Initiates the download restoration process."""
if self._is_download_active():
@@ -4574,6 +4561,294 @@ class DownloaderApp (QWidget ):
self .log_signal .emit (f" {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
self._update_error_button_count()
def _start_batch_update_check(self, profiles_list):
"""Launches a background thread to check multiple profiles for updates."""
self.set_ui_enabled(False)
self.progress_label.setText(self._tr("batch_update_checking", "Checking for updates..."))
self.cancellation_event.clear()
# Start the background thread
self.download_thread = threading.Thread(
target=self._run_batch_update_check_thread,
args=(profiles_list,),
daemon=True
)
self.download_thread.start()
self._update_button_states_and_connections()
def _run_batch_update_check_thread(self, profiles_list):
"""
(BACKGROUND THREAD)
Iterates profiles, calls download_from_api for each, and collects new posts.
"""
master_new_post_list = []
total_profiles = len(profiles_list)
for i, profile in enumerate(profiles_list):
if self.cancellation_event.is_set():
break
profile_name = profile.get('name', 'Unknown')
self.log_signal.emit(f"Checking {profile_name} ({i+1}/{total_profiles})...")
try:
profile_data = profile.get('data', {})
url = profile_data.get('creator_url', [])[0] # Get first URL
processed_ids = set(profile_data.get('processed_post_ids', []))
creator_settings = profile_data.get('settings', {})
# Use common cookie settings from the UI
use_cookie = self.use_cookie_checkbox.isChecked()
cookie_text = self.cookie_text_input.text()
cookie_file = self.selected_cookie_filepath
post_generator = download_from_api(
api_url_input=url,
logger=lambda msg: None, # Suppress logs
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=use_cookie,
cookie_text=cookie_text,
selected_cookie_file=cookie_file,
app_base_dir=self.app_base_dir,
processed_post_ids=processed_ids,
end_page=5
)
for post_batch in post_generator:
if self.cancellation_event.is_set(): break
for post_data in post_batch:
# Store the post AND the ENTIRE profile data
master_new_post_list.append({
'post_data': post_data,
'profile_data': profile_data, # Pass the full profile
'creator_name': profile_name
})
except Exception as e:
self.log_signal.emit(f"❌ Error checking {profile_name}: {e}")
# Emit the final aggregated list
self.batch_update_check_complete_signal.emit(master_new_post_list)
def _batch_update_check_finished(self, all_new_posts_list):
"""
(GUI THREAD)
Called when the batch update check is complete. Updates UI.
"""
self.download_thread = None # Clear the thread
if self.cancellation_event.is_set():
self.log_signal.emit(" Update check was cancelled.")
self.reset_application_state() # Full reset
return
if not all_new_posts_list:
self.log_signal.emit("✅ All selected creators are up to date! No new posts found.")
QMessageBox.information(self, "Up to Date", "No new posts were found for the selected creators.")
self.reset_application_state() # Full reset
return
total_posts = len(all_new_posts_list)
# --- MODIFIED BLOCK ---
# Get the set of unique creator names who have new posts
creators_with_new_posts = sorted(list(set(p['creator_name'] for p in all_new_posts_list)))
total_creators = len(creators_with_new_posts)
self.log_signal.emit("=" * 40)
# Add the new line you requested
if creators_with_new_posts:
self.log_signal.emit(f"Creators With New Posts - {', '.join(creators_with_new_posts)}")
# Log the original summary line
self.log_signal.emit(f"✅ Update check complete. Found {total_posts} new post(s) across {total_creators} creator(s).")
# --- END OF MODIFIED BLOCK ---
self.log_signal.emit(" Click 'Start Download' to begin.")
self.fetched_posts_for_batch_update = all_new_posts_list
self.is_ready_to_download_batch_update = True
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
self.set_ui_enabled(True) # Re-enable UI
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
def _start_download_of_batch_update(self):
"""
(GUI THREAD)
Initiates the download of the posts found during the batch update check.
--- THIS IS THE CORRECTED ROBUST VERSION ---
"""
self.is_ready_to_download_batch_update = False
self.log_signal.emit("=" * 40)
self.log_signal.emit(f"🚀 Starting batch download for {len(self.fetched_posts_for_batch_update)} new post(s)...")
if self.main_log_output: self.main_log_output.clear()
if self.external_log_output: self.external_log_output.clear()
if self.missed_character_log_output: self.missed_character_log_output.clear()
self.missed_key_terms_buffer.clear()
self.already_logged_bold_key_terms.clear()
self.set_ui_enabled(False)
num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1
self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='PostWorker_')
self.total_posts_to_process = len(self.fetched_posts_for_batch_update)
self.processed_posts_count = 0
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
# 1. Define all LIVE RUNTIME arguments.
# These are taken from the current app state and are the same for all workers.
live_runtime_args = {
'emitter': self.worker_to_gui_queue,
'creator_name_cache': self.creator_name_cache,
'known_names': list(KNOWN_NAMES),
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
'pause_event': self.pause_event,
'cancellation_event': self.cancellation_event,
'downloaded_files': self.downloaded_files,
'downloaded_files_lock': self.downloaded_files_lock,
'downloaded_file_hashes': self.downloaded_file_hashes,
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
'num_file_threads': 1, # File threads per post worker
'manga_date_file_counter_ref': None,
'manga_global_file_counter_ref': None,
'creator_download_folder_ignore_words': CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS,
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'skip_current_file_flag': None,
'session_file_path': self.session_file_path,
'session_lock': self.session_lock,
'project_root_dir': self.app_base_dir,
'app_base_dir': self.app_base_dir,
'start_offset': 0,
'fetch_first': False,
# Add live cookie settings
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
}
# 2. Define DEFAULTS for all settings that *should* be in the profile.
# These will be used if the profile is old and missing a key.
default_profile_settings = {
'output_dir': self.dir_input.text().strip(), # Fallback to live UI
'api_url': '',
'character_filter_text': '',
'skip_words_text': '',
'remove_words_text': '',
'custom_folder_name': None,
'filter_mode': 'all',
'text_only_scope': None,
'text_export_format': 'txt',
'single_pdf_mode': False,
'skip_zip': False,
'use_subfolders': False,
'use_post_subfolders': False,
'compress_images': False,
'download_thumbnails': False,
'skip_words_scope': SKIP_SCOPE_FILES,
'char_filter_scope': CHAR_SCOPE_FILES,
'show_external_links': False,
'extract_links_only': False,
'manga_mode_active': False,
'manga_filename_style': STYLE_POST_TITLE,
'allow_multipart_download': False,
'manga_date_prefix': '',
'scan_content_for_images': False,
'use_date_prefix_for_subfolder': False,
'date_prefix_format': "YYYY-MM-DD {post}",
'keep_in_post_duplicates': False,
'keep_duplicates_mode': DUPLICATE_HANDLING_HASH,
'keep_duplicates_limit': 0,
'multipart_scope': 'both',
'multipart_parts_count': 4,
'multipart_min_size_mb': 100,
'manga_custom_filename_format': "{published} {title}",
'manga_custom_date_format': "YYYY-MM-DD",
'target_post_id_from_initial_url': None,
'override_output_dir': None,
'processed_post_ids': [],
}
for item in self.fetched_posts_for_batch_update:
post_data = item['post_data']
# --- THIS IS THE NEW, CORRECTED LOGIC ---
full_profile_data = item.get('profile_data', {})
saved_settings = full_profile_data.get('settings', {})
# --- END OF NEW LOGIC ---
# 3. Construct the final arguments for this specific worker
# Start with a full set of defaults
args_for_this_worker = default_profile_settings.copy()
# Overwrite with any settings saved in the profile
# This is where {"filter_mode": "video"} from Maplestar.json is applied
args_for_this_worker.update(saved_settings)
# Add all the live runtime arguments
args_for_this_worker.update(live_runtime_args)
# 4. Manually parse values from the constructed args
# Set post-specific data
args_for_this_worker['service'] = post_data.get('service')
args_for_this_worker['user_id'] = post_data.get('user')
# Set download_root (which worker expects) from output_dir
args_for_this_worker['download_root'] = args_for_this_worker.get('output_dir')
# Parse filters and commands
raw_filters = args_for_this_worker.get('character_filter_text', '')
parsed_filters, commands = self._parse_character_filters(raw_filters)
args_for_this_worker['filter_character_list'] = parsed_filters
args_for_this_worker['domain_override'] = commands.get('domain_override')
args_for_this_worker['archive_only_mode'] = commands.get('archive_only', False)
args_for_this_worker['sfp_threshold'] = commands.get('sfp_threshold')
args_for_this_worker['handle_unknown_mode'] = commands.get('handle_unknown', False)
# Parse skip words and skip size
skip_words_parts = [part.strip() for part in args_for_this_worker.get('skip_words_text', '').split(',') if part.strip()]
args_for_this_worker['skip_file_size_mb'] = None
args_for_this_worker['skip_words_list'] = []
size_pattern = re.compile(r'\[(\d+)\]')
for part in skip_words_parts:
match = size_pattern.fullmatch(part)
if match:
args_for_this_worker['skip_file_size_mb'] = int(match.group(1))
else:
args_for_this_worker['skip_words_list'].append(part.lower())
# Parse remove_from_filename_words_list
raw_remove_words = args_for_this_worker.get('remove_words_text', '')
args_for_this_worker['remove_from_filename_words_list'] = [word.strip() for word in raw_remove_words.split(',') if word.strip()]
# Ensure processed_post_ids is a list (from the *original* profile data)
args_for_this_worker['processed_post_ids'] = list(full_profile_data.get('processed_post_ids', []))
# Ensure api_url_input is set
args_for_this_worker['api_url_input'] = args_for_this_worker.get('api_url', '')
self._submit_post_to_worker_pool(
post_data,
args_for_this_worker,
1, # File threads per worker (1 for sequential batch)
self.worker_to_gui_queue,
ppw_expected_keys,
{}
)
self.fetched_posts_for_batch_update = []
self.is_fetcher_thread_running = False
self._check_if_all_work_is_done()
def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
"""Helper to prepare and submit a single post processing task to the thread pool."""
global PostProcessorWorker
@@ -5173,56 +5448,76 @@ class DownloaderApp (QWidget ):
self ._filter_links_log ()
def cancel_download_button_action(self):
"""
Handles the user clicking the 'Cancel' button.
This version forcefully shuts down thread pools.
"""
if not self._is_download_active() and not self.is_paused:
self.log_signal.emit(" Cancel button clicked, but no download is active.")
return
if self.is_paused:
self.log_signal.emit("❌ Cancellation requested while paused. Stopping all workers...")
if self._is_download_active() and hasattr(self.download_thread, 'cancel'):
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
self.download_thread.cancel()
else:
# Fallback for other download types
self.cancellation_event.set()
# Update UI to "Cancelling" state
self.pause_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
if hasattr(self, 'reset_button'):
self.reset_button.setEnabled(False)
self.log_signal.emit("❌ Cancellation requested by user. Stopping all workers...")
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
self.pause_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
if hasattr(self, 'reset_button'):
self.reset_button.setEnabled(False)
# 1. Set the master cancellation event
# This tells all workers to stop *cooperatively*
if not self.cancellation_event.is_set():
self.cancellation_event.set()
# Only call QThread-specific methods if the thread is a QThread
# 2. Forcefully shut down QThreads
if self.download_thread and hasattr(self.download_thread, 'requestInterruption'):
self.download_thread.requestInterruption()
self.log_signal.emit(" Signaled single download thread to interrupt.")
if self.thread_pool:
self.log_signal.emit(" Signaling worker pool to cancel futures...")
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.log_signal.emit(" Cancelling active External Link download thread...")
self.external_link_download_thread.cancel()
# ... (add any other QThread .cancel() calls here if you have them) ...
if isinstance(self.download_thread, NhentaiDownloadThread):
self.log_signal.emit(" Signaling nhentai download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, BunkrDownloadThread):
self.log_signal.emit(" Signaling Bunkr download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, Saint2DownloadThread):
self.log_signal.emit(" Signaling Saint2 download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, EromeDownloadThread):
self.log_signal.emit(" Signaling Erome download thread to cancel.")
self.download_thread.cancel()
if isinstance(self.download_thread, Hentai2readDownloadThread):
self.log_signal.emit(" Signaling Hentai2Read download thread to cancel.")
self.download_thread.cancel()
# 3. Forcefully shut down ThreadPoolExecutors
# This is the critical fix for batch/update downloads
if self.thread_pool:
self.log_signal.emit(" Signaling worker pool to shut down...")
# We use cancel_futures=True to actively stop pending tasks
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.thread_pool = None
self.active_futures = []
self.log_signal.emit(" Worker pool shutdown initiated.")
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Signaling retry worker pool to shut down...")
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
self.retry_thread_pool = None
self.active_retry_futures = []
self.log_signal.emit(" Retry pool shutdown initiated.")
# 4. Manually trigger the 'finished' logic to reset the UI
# This is safe because we just shut down all the threads
self.download_finished(0, 0, True, [])
def _get_domain_for_service(self, service_name: str) -> str:
"""Determines the base domain for a given service."""
@@ -5265,6 +5560,8 @@ class DownloaderApp (QWidget ):
self.download_thread.deleteLater()
self.download_thread = None # This is the crucial line
self.is_finishing = False
self.finish_lock.release()
self._process_next_favorite_download()
return
@@ -5571,7 +5868,14 @@ class DownloaderApp (QWidget ):
'target_post_id_from_initial_url':None ,
'custom_folder_name':None ,
'num_file_threads':1 ,
# --- START: ADDED COOKIE FIX ---
'use_cookie': self.use_cookie_checkbox.isChecked(),
'cookie_text': self.cookie_text_input.text(),
'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir,
# --- END: ADDED COOKIE FIX ---
'manga_date_file_counter_ref':None ,
}
@@ -5918,6 +6222,8 @@ class DownloaderApp (QWidget ):
self.is_fetching_only = False
self.fetched_posts_for_download = []
self.is_ready_to_download_fetched = False
self.fetched_posts_for_batch_update = []
self.is_ready_to_download_batch_update = False
self.allcomic_warning_shown = False
self.set_ui_enabled(True)
@@ -6268,23 +6574,18 @@ class DownloaderApp (QWidget ):
self._tr("restore_pending_message_creator_selection",
"Please 'Restore Download' or 'Discard Session' before selecting new creators."))
return
dialog = EmptyPopupDialog(self.app_base_dir, self)
dialog = EmptyPopupDialog(self.user_data_path, self)
if dialog.exec_() == QDialog.Accepted:
if dialog.update_profile_data:
self.active_update_profile = dialog.update_profile_data
self.link_input.setText(dialog.update_creator_name)
self.favorite_download_queue.clear()
if 'settings' in self.active_update_profile:
self.log_signal.emit(f" Applying saved settings from '{dialog.update_creator_name}' profile...")
self._load_ui_from_settings_dict(self.active_update_profile['settings'])
self.log_signal.emit(" Settings restored.")
# --- NEW BATCH UPDATE LOGIC ---
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
self.active_update_profiles_list = dialog.update_profiles_list
self.log_signal.emit(f" Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...")
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
self._start_batch_update_check(self.active_update_profiles_list)
self.log_signal.emit(f" Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
self._update_button_states_and_connections()
# --- Original logic for adding creators to queue ---
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None
self.active_update_profile = None # Ensure single update mode is off
self.favorite_download_queue.clear()
for creator_data in dialog.selected_creators_for_queue:
@@ -6314,9 +6615,7 @@ class DownloaderApp (QWidget ):
if hasattr(self, 'link_input'):
self.last_link_input_text_for_queue_sync = self.link_input.text()
# Manually trigger the UI update now that the queue is populated and the dialog is closed.
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
def _load_saved_cookie_settings(self):
"""Loads and applies saved cookie settings on startup."""
try:
@@ -6477,26 +6776,6 @@ class DownloaderApp (QWidget ):
char_filter_is_empty = not self.character_input.text().strip()
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
if manga_mode_is_checked and char_filter_is_empty and not extract_links_only:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Renaming Mode Filter Warning")
msg_box.setText(
"Renaming Mode is enabled, but 'Filter by Character(s)' is empty.\n\n"
"This is a one-time warning for this entire batch of downloads.\n\n"
"Proceeding without a filter may result in generic filenames and folders.\n\n"
"Proceed with the entire batch?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Entire Batch", QMessageBox.RejectRole)
msg_box.exec_()
if msg_box.clickedButton() == cancel_button:
self.log_signal.emit("❌ Entire favorite queue cancelled by user at Renaming Mode warning.")
self.favorite_download_queue.clear()
self.is_processing_favorites_queue = False
self.set_ui_enabled(True)
return # Stop processing the queue
if self ._is_download_active ():
self .log_signal .emit (" Waiting for current download to finish before starting next favorite.")
return

111
structure.txt Normal file
View File

@@ -0,0 +1,111 @@
├── assets/
│ ├── Kemono.ico
│ ├── Kemono.png
│ ├── Ko-fi.png
│ ├── buymeacoffee.png
│ ├── discord.png
│ ├── github.png
│ ├── instagram.png
│ └── patreon.png
├── data/
│ ├── creators.json
│ └── dejavu-sans/
│ ├── DejaVu Fonts License.txt
│ ├── DejaVuSans-Bold.ttf
│ ├── DejaVuSans-BoldOblique.ttf
│ ├── DejaVuSans-ExtraLight.ttf
│ ├── DejaVuSans-Oblique.ttf
│ ├── DejaVuSans.ttf
│ ├── DejaVuSansCondensed-Bold.ttf
│ ├── DejaVuSansCondensed-BoldOblique.ttf
│ ├── DejaVuSansCondensed-Oblique.ttf
│ └── DejaVuSansCondensed.ttf
├── directory_tree.txt
├── main.py
├── src/
│ ├── __init__.py
│ ├── config/
│ │ ├── __init__.py
│ │ └── constants.py
│ ├── core/
│ │ ├── Hentai2read_client.py
│ │ ├── __init__.py
│ │ ├── allcomic_client.py
│ │ ├── api_client.py
│ │ ├── booru_client.py
│ │ ├── bunkr_client.py
│ │ ├── discord_client.py
│ │ ├── erome_client.py
│ │ ├── fap_nation_client.py
│ │ ├── manager.py
│ │ ├── mangadex_client.py
│ │ ├── nhentai_client.py
│ │ ├── pixeldrain_client.py
│ │ ├── rule34video_client.py
│ │ ├── saint2_client.py
│ │ ├── simpcity_client.py
│ │ ├── toonily_client.py
│ │ └── workers.py
│ ├── i18n/
│ │ ├── __init__.py
│ │ └── translator.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── drive_downloader.py
│ │ ├── multipart_downloader.py
│ │ └── updater.py
│ ├── ui/
│ │ ├── __init__.py
│ │ ├── assets.py
│ │ ├── classes/
│ │ │ ├── allcomic_downloader_thread.py
│ │ │ ├── booru_downloader_thread.py
│ │ │ ├── bunkr_downloader_thread.py
│ │ │ ├── discord_downloader_thread.py
│ │ │ ├── downloader_factory.py
│ │ │ ├── drive_downloader_thread.py
│ │ │ ├── erome_downloader_thread.py
│ │ │ ├── external_link_downloader_thread.py
│ │ │ ├── fap_nation_downloader_thread.py
│ │ │ ├── hentai2read_downloader_thread.py
│ │ │ ├── kemono_discord_downloader_thread.py
│ │ │ ├── mangadex_downloader_thread.py
│ │ │ ├── nhentai_downloader_thread.py
│ │ │ ├── pixeldrain_downloader_thread.py
│ │ │ ├── rule34video_downloader_thread.py
│ │ │ ├── saint2_downloader_thread.py
│ │ │ ├── simp_city_downloader_thread.py
│ │ │ └── toonily_downloader_thread.py
│ │ ├── dialogs/
│ │ │ ├── ConfirmAddAllDialog.py
│ │ │ ├── CookieHelpDialog.py
│ │ │ ├── CustomFilenameDialog.py
│ │ │ ├── DownloadExtractedLinksDialog.py
│ │ │ ├── DownloadHistoryDialog.py
│ │ │ ├── EmptyPopupDialog.py
│ │ │ ├── ErrorFilesDialog.py
│ │ │ ├── ExportLinksDialog.py
│ │ │ ├── ExportOptionsDialog.py
│ │ │ ├── FavoriteArtistsDialog.py
│ │ │ ├── FavoritePostsDialog.py
│ │ │ ├── FutureSettingsDialog.py
│ │ │ ├── HelpGuideDialog.py
│ │ │ ├── KeepDuplicatesDialog.py
│ │ │ ├── KnownNamesFilterDialog.py
│ │ │ ├── MoreOptionsDialog.py
│ │ │ ├── MultipartScopeDialog.py
│ │ │ ├── SinglePDF.py
│ │ │ ├── SupportDialog.py
│ │ │ ├── TourDialog.py
│ │ │ ├── __init__.py
│ │ │ └── discord_pdf_generator.py
│ │ └── main_window.py
│ └── utils/
│ ├── __init__.py
│ ├── command.py
│ ├── file_utils.py
│ ├── network_utils.py
│ ├── resolution.py
│ └── text_utils.py
├── structure.txt
└── yt-dlp.exe