This commit is contained in:
Yuvi9587
2025-07-22 07:00:34 -07:00
parent 2785fc1121
commit d54b013bbc
8 changed files with 712 additions and 230 deletions

View File

@@ -59,6 +59,7 @@ LANGUAGE_KEY = "currentLanguageV1"
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
# --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>"

View File

@@ -41,6 +41,9 @@ class DownloadManager:
self.total_downloads = 0
self.total_skips = 0
self.all_kept_original_filenames = []
self.creator_profiles_dir = None
self.current_creator_name_for_profile = None
self.current_creator_profile_path = None
def _log(self, message):
"""Puts a progress message into the queue for the UI."""
@@ -58,6 +61,13 @@ class DownloadManager:
if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.")
return
creator_profile_data = self._setup_creator_profile(config)
creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_profile_data)
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
self.is_running = True
self.cancellation_event.clear()
self.pause_event.clear()
@@ -72,11 +82,11 @@ class DownloadManager:
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts:
fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data),
args=(config, restore_data, creator_profile_data), # Add argument here
daemon=True
)
fetcher_thread.start()
@@ -112,6 +122,11 @@ class DownloadManager:
try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set()
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_ids)
if restore_data:
all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids'])
@@ -196,12 +211,52 @@ class DownloadManager:
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
if history:
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
post_id = history.get('post_id')
if post_id and self.current_creator_profile_path:
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(profile_data)
except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile."""
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
if not self.current_creator_name_for_profile:
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
return {}
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(self.creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
if os.path.exists(self.current_creator_profile_path):
try:
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, data):
"""Saves the provided data to the current creator's profile file."""
if not self.current_creator_profile_path:
return
try:
temp_path = self.current_creator_profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, self.current_creator_profile_path)
except OSError as e:
self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}")
def cancel_session(self):
"""Cancels the current running session."""
if not self.is_running:

View File

@@ -238,13 +238,24 @@ class PostProcessorWorker:
if self.manga_mode_active:
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
filename_to_save_in_main_path = cleaned_original_api_filename
if self.manga_date_prefix and self.manga_date_prefix.strip():
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
if cleaned_prefix:
filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}"
else:
self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.")
# Get the post's publication or added date
published_date_str = self.post.get('published')
added_date_str = self.post.get('added')
formatted_date_str = "nodate" # Fallback if no date is found
date_to_use_str = published_date_str or added_date_str
if date_to_use_str:
try:
# Extract just the YYYY-MM-DD part from the timestamp
formatted_date_str = date_to_use_str.split('T')[0]
except Exception:
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
else:
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
# Combine the date with the cleaned original filename
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
was_original_name_kept_flag = True
elif self.manga_filename_style == STYLE_POST_TITLE:
if post_title and post_title.strip():
@@ -1385,7 +1396,17 @@ class PostProcessorWorker:
if not all_files_from_post_api:
self.logger(f" No files found to download for post {post_id}.")
result_tuple = (0, 0, [], [], [], None, None)
history_data_for_no_files_post = {
'post_title': post_title,
'post_id': post_id,
'service': self.service,
'user_id': self.user_id,
'top_file_name': "N/A (No Files)",
'num_files': 0,
'upload_date_str': post_data.get('published') or post_data.get('added') or "Unknown",
'download_location': determined_post_save_path_for_history
}
result_tuple = (0, 0, [], [], [], history_data_for_no_files_post, None)
return result_tuple
files_to_download_info_list = []

View File

@@ -15,9 +15,9 @@ except ImportError:
try:
import gdown
GDOWN_AVAILABLE = True
GDRIVE_AVAILABLE = True
except ImportError:
GDOWN_AVAILABLE = False
GDRIVE_AVAILABLE = False
# --- Helper Functions ---
@@ -46,75 +46,76 @@ def _get_filename_from_headers(headers):
# --- Main Service Downloader Functions ---
def download_mega_file(mega_link, download_path=".", logger_func=print):
def download_mega_file(mega_url, download_path, logger_func=print):
"""
Downloads a file from a public Mega.nz link.
Args:
mega_link (str): The public Mega.nz link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
Downloads a file from a Mega.nz URL.
Handles both public links and links that include a decryption key.
"""
if not MEGA_AVAILABLE:
logger_func("Error: mega.py library is not installed. Cannot download from Mega.")
logger_func(" Please install it: pip install mega.py")
raise ImportError("mega.py library not found.")
logger_func("Mega download failed: 'mega.py' library is not installed.")
return
logger_func(f" [Mega] Initializing Mega client...")
try:
mega_client = Mega()
m = mega_client.login()
logger_func(f" [Mega] Attempting to download from: {mega_link}")
if not os.path.exists(download_path):
os.makedirs(download_path, exist_ok=True)
logger_func(f" [Mega] Created download directory: {download_path}")
# The download_url method handles file info fetching and saving internally.
downloaded_file_path = m.download_url(mega_link, dest_path=download_path)
mega = Mega()
# Anonymous login is sufficient for public links
m = mega.login()
if downloaded_file_path and os.path.exists(downloaded_file_path):
logger_func(f" [Mega] ✅ File downloaded successfully! Saved as: {downloaded_file_path}")
else:
raise Exception(f"Mega download failed or file not found. Returned: {downloaded_file_path}")
# --- MODIFIED PART: Added error handling for invalid links ---
try:
file_details = m.find(mega_url)
if file_details is None:
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
return
except (ValueError, json.JSONDecodeError) as e:
# This block catches the "Expecting value" error
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
return
except Exception as e:
# Catch other potential errors from the mega.py library
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
return
# --- END OF MODIFIED PART ---
filename = file_details[1]['a']['n']
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
# Sanitize filename before saving
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
final_path = os.path.join(download_path, safe_filename)
# Check if file already exists
if os.path.exists(final_path):
logger_func(f" [Mega] File '{safe_filename}' already exists. Skipping download.")
return
# Start the download
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
except Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during Mega download: {e}")
traceback.print_exc(limit=2)
raise # Re-raise the exception to be handled by the calling worker
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
def download_gdrive_file(gdrive_link, download_path=".", logger_func=print):
"""
Downloads a file from a public Google Drive link using the gdown library.
Args:
gdrive_link (str): The public Google Drive link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
"""
if not GDOWN_AVAILABLE:
logger_func("❌ Error: gdown library is not installed. Cannot download from Google Drive.")
logger_func(" Please install it: pip install gdown")
raise ImportError("gdown library not found.")
logger_func(f" [GDrive] Attempting to download: {gdrive_link}")
def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link."""
if not GDRIVE_AVAILABLE:
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
return
try:
if not os.path.exists(download_path):
os.makedirs(download_path, exist_ok=True)
logger_func(f" [GDrive] Created download directory: {download_path}")
# gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats.
output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True)
if output_file_path and os.path.exists(output_file_path):
logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}")
logger_func(f" [G-Drive] Starting download for: {url}")
# --- MODIFIED PART: Added a message and set quiet=True ---
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
# --- END OF MODIFIED PART ---
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
else:
raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}")
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
except Exception as e:
logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}")
traceback.print_exc(limit=2)
raise
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
"""

View File

@@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
QSplitter, QProgressBar, QWidget
QSplitter, QProgressBar, QWidget, QFileDialog
)
# --- Local Application Imports ---
@@ -151,6 +151,8 @@ class EmptyPopupDialog (QDialog ):
app_icon =get_app_icon_object ()
if app_icon and not app_icon .isNull ():
self .setWindowIcon (app_icon )
self.update_profile_data = None
self.update_creator_name = None
self .selected_creators_for_queue =[]
self .globally_selected_creators ={}
self .fetched_posts_data ={}
@@ -205,6 +207,9 @@ class EmptyPopupDialog (QDialog ):
self .scope_button .clicked .connect (self ._toggle_scope_mode )
left_bottom_buttons_layout .addWidget (self .scope_button )
left_pane_layout .addLayout (left_bottom_buttons_layout )
self.update_button = QPushButton()
self.update_button.clicked.connect(self._handle_update_check)
left_bottom_buttons_layout.addWidget(self.update_button)
self .right_pane_widget =QWidget ()
@@ -315,6 +320,31 @@ class EmptyPopupDialog (QDialog ):
except AttributeError :
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")
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}")
def _handle_fetch_posts_click (self ):
selected_creators =list (self .globally_selected_creators .values ())
print(f"[DEBUG] Selected creators for fetch: {selected_creators}")
@@ -370,6 +400,7 @@ class EmptyPopupDialog (QDialog ):
self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected"))
self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts"))
self ._update_scope_button_text_and_tooltip ()
self.update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))

View File

@@ -6,7 +6,7 @@ import json
from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
)
# --- Local Application Imports ---
@@ -15,7 +15,7 @@ from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object
from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY
)
@@ -35,7 +35,7 @@ class FutureSettingsDialog(QDialog):
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0
base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
base_min_w, base_min_h = 420, 360 # Adjusted height for new layout
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)
@@ -93,6 +93,11 @@ class FutureSettingsDialog(QDialog):
download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1)
# Save Creator.json Checkbox
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, 2, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box)
main_layout.addStretch(1)
@@ -102,6 +107,20 @@ class FutureSettingsDialog(QDialog):
self.ok_button.clicked.connect(self.accept)
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
def _load_checkbox_states(self):
"""Loads the initial state for all checkboxes from settings."""
self.save_creator_json_checkbox.blockSignals(True)
# Default to True so the feature is on by default for users
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
self.save_creator_json_checkbox.setChecked(should_save)
self.save_creator_json_checkbox.blockSignals(False)
def _creator_json_setting_changed(self, state):
"""Saves the state of the 'Save Creator.json' checkbox."""
is_checked = state == Qt.Checked
self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked)
self.parent_app.settings.sync()
def _tr(self, key, default_text=""):
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
@@ -122,6 +141,7 @@ class FutureSettingsDialog(QDialog):
# Download & Window Group Labels
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
# Buttons and Controls
self._update_theme_toggle_button_text()
@@ -132,6 +152,7 @@ class FutureSettingsDialog(QDialog):
# Populate dropdowns
self._populate_display_combo_boxes()
self._populate_language_combo_box()
self._load_checkbox_states()
def _apply_theme(self):
if self.parent_app and self.parent_app.current_theme == "dark":

View File

@@ -97,6 +97,8 @@ class DownloaderApp (QWidget ):
def __init__(self):
super().__init__()
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
self.active_update_profile = None
self.new_posts_for_update = []
self.is_finishing = False
saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
@@ -113,9 +115,13 @@ class DownloaderApp (QWidget ):
else:
self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
self.config_file = os.path.join(self.app_base_dir, "appdata", "Known.txt")
self.session_file_path = os.path.join(self.app_base_dir, "appdata", "session.json")
self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json")
executable_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else self.app_base_dir
user_data_path = os.path.join(executable_dir, "appdata")
os.makedirs(user_data_path, exist_ok=True)
self.config_file = os.path.join(user_data_path, "Known.txt")
self.session_file_path = os.path.join(user_data_path, "session.json")
self.persistent_history_file = os.path.join(user_data_path, "download_history.json")
self.download_thread = None
self.thread_pool = None
@@ -222,6 +228,7 @@ class DownloaderApp (QWidget ):
self.downloaded_hash_counts = defaultdict(int)
self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = []
self.save_creator_json_enabled_this_session = True
print(f" Known.txt will be loaded/saved at: {self.config_file}")
@@ -253,7 +260,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 v6.1.0")
self.setWindowTitle("Kemono Downloader v6.2.0")
setup_ui(self)
self._connect_signals()
self.log_signal.emit(" Local API server functionality has been removed.")
@@ -294,6 +301,45 @@ class DownloaderApp (QWidget ):
if msg_box.clickedButton() == restart_button:
self._request_restart_application()
def _setup_creator_profile(self, creator_name, session_file_path):
"""Prepares the path and loads data for the current creator's profile."""
if not creator_name:
self.log_signal.emit("⚠️ Cannot create creator profile: Name not provided.")
return {}
appdata_dir = os.path.dirname(session_file_path)
creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(creator_name) + ".json"
profile_path = os.path.join(creator_profiles_dir, safe_filename)
if os.path.exists(profile_path):
try:
with open(profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self.log_signal.emit(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, creator_name, data, session_file_path):
"""Saves the provided data to the current creator's profile file."""
if not creator_name:
return
appdata_dir = os.path.dirname(session_file_path)
creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
safe_filename = clean_folder_name(creator_name) + ".json"
profile_path = os.path.join(creator_profiles_dir, safe_filename)
try:
temp_path = profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, profile_path)
except OSError as e:
self.log_signal.emit(f"❌ Error saving creator profile to '{profile_path}': {e}")
def _create_initial_session_file(self, api_url_for_session, override_output_dir_for_session, remaining_queue=None):
"""Creates the initial session file at the start of a new download."""
if self.is_restore_pending:
@@ -478,7 +524,7 @@ class DownloaderApp (QWidget ):
def _update_button_states_and_connections(self):
"""
Updates the text and click connections of the main action buttons
based on the current application state (downloading, paused, restore pending, idle).
based on the current application state.
"""
try: self.download_btn.clicked.disconnect()
except TypeError: pass
@@ -489,7 +535,38 @@ class DownloaderApp (QWidget ):
is_download_active = self._is_download_active()
if self.is_restore_pending:
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)
self.download_btn.setText(self._tr("start_download_new_button_text", f"⬇️ Start Download ({num_new} new)"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.download_btn.setToolTip(self._tr("start_download_new_tooltip", "Click to download the new posts found."))
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False)
self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_update_selection)
self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to cancel the update and clear the selection."))
elif self.active_update_profile and not is_download_active:
# State: Update check (profile loaded, waiting for user to check)
self.download_btn.setText(self._tr("check_for_updates_button_text", "🔄 Check For Updates"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.download_btn.setToolTip(self._tr("check_for_updates_tooltip", "Click to check for new posts from this creator."))
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False)
self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_update_selection)
self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to clear the loaded creator profile and return to normal mode."))
elif self.is_restore_pending:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
@@ -507,7 +584,7 @@ class DownloaderApp (QWidget ):
elif is_download_active:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(False) # Cannot start new download while one is active
self.download_btn.setEnabled(False)
self.pause_btn.setText(self._tr("resume_download_button_text", "▶️ Resume Download") if self.is_paused else self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(True)
@@ -524,13 +601,19 @@ class DownloaderApp (QWidget ):
self.download_btn.clicked.connect(self.start_download)
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False) # No active download to pause
self.pause_btn.setEnabled(False)
self.pause_btn.setToolTip(self._tr("pause_download_button_tooltip", "Click to pause the ongoing download process."))
self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
self.cancel_btn.setEnabled(False) # No active download to cancel
self.cancel_btn.setEnabled(False)
self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
def _clear_update_selection(self):
"""Clears the loaded creator profile and fully resets the UI to its default state."""
self.log_signal.emit(" Update selection cleared. Resetting UI to defaults.")
self.active_update_profile = None
self.new_posts_for_update = []
self._reset_ui_to_defaults()
def _retranslate_main_ui (self ):
"""Retranslates static text elements in the main UI."""
@@ -1618,42 +1701,75 @@ class DownloaderApp (QWidget ):
def _display_and_schedule_next (self ,link_data ):
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode:
# --- FONT STYLE DEFINITION ---
# Use a clean, widely available sans-serif font family for a modern look.
font_style = "font-family: 'Segoe UI', Helvetica, Arial, sans-serif;"
if is_only_links_mode :
if post_title != self._current_link_post_title:
if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html)
title_html = f'{HTML_PREFIX}<h3 style="color: #87CEEB; margin-bottom: 5px; margin-top: 8px;">{html.escape(post_title)}</h3>'
self.log_signal.emit(title_html)
# Apply font style to the title
title_html = f'<p style="{font_style} font-size: 11pt; color: #87CEEB; margin-top: 2px; margin-bottom: 2px;"><b>{html.escape(post_title)}</b></p>'
self.log_signal.emit(HTML_PREFIX + title_html)
self._current_link_post_title = post_title
display_text = html.escape(link_text.strip() if link_text.strip() else link_url)
link_html_parts = [
f'<div style="margin-left: 20px; margin-bottom: 4px;">'
f'• <a href="{link_url}" style="color: #A9D0F5; text-decoration: none;">{display_text}</a>'
f' <span style="color: #999;">({html.escape(platform)})</span>'
]
# Use the "smarter" logic to decide what text to show for the link
cleaned_link_text = link_text.strip()
display_text = ""
if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url:
display_text = cleaned_link_text
else:
try:
path = urlparse(link_url).path
filename = os.path.basename(path)
if filename:
display_text = filename
except Exception:
pass
if not display_text:
display_text = link_url
# Truncate long display text
if len(display_text) > 50:
display_text = display_text[:50].strip() + "..."
# Format the output as requested
platform_display = platform.capitalize()
# Escape parts that will be displayed as text
escaped_url = html.escape(link_url)
escaped_display_text = html.escape(f"({display_text})")
# Apply font style to the link information and wrap in a paragraph tag
link_html_line = (
f'<p style="{font_style} font-size: 9.5pt; margin-left: 10px; margin-top: 1px; margin-bottom: 1px;">'
f" <span style='color: #E0E0E0;'>{platform_display} - {escaped_url} - {escaped_display_text}</span>"
)
if decryption_key:
link_html_parts.append(
f'<br><span style="margin-left: 15px; color: #f0ad4e; font-size: 9pt;">'
f'Key: {html.escape(decryption_key)}</span>'
)
escaped_key = html.escape(f"(Decryption Key: {decryption_key})")
link_html_line += f" <span style='color: #f0ad4e;'>{escaped_key}</span>"
link_html_parts.append('</div>')
final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}'
self.log_signal.emit(final_link_html)
link_html_line += '</p>'
# Emit the entire line as a single HTML signal
self.log_signal.emit(HTML_PREFIX + link_html_line)
elif self .show_external_links :
separator ="-"*45
# This part for the secondary log remains unchanged
separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}"
if decryption_key:
formatted_link_info += f" (Decryption Key: {decryption_key})"
self._append_to_external_log(formatted_link_info, separator)
self ._is_processing_external_link_queue =False
self ._is_processing_external_link_queue =False
self ._try_process_next_external_link ()
@@ -1912,70 +2028,97 @@ class DownloaderApp (QWidget ):
def _filter_links_log (self ):
if not (self .radio_only_links and self .radio_only_links .isChecked ()):return
if not (self .radio_only_links and self .radio_only_links .isChecked ()):return
search_term =self .link_search_input .text ().lower ().strip ()if self .link_search_input else ""
# This block handles the "Download Progress" view for Mega/Drive links and should be kept
if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log (due to mega_download_log_preserved_once).")
self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log.")
return
elif self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
self .log_signal .emit ("INTERNAL: _filter_links_log - In Progress View. Clearing for placeholder.")
if self .main_log_output :self .main_log_output .clear ()
self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for progress placeholder.")
self .log_signal .emit (" Switched to Mega download progress view. Extracted links are hidden.\n"
" Perform a Mega download to see its progress here, or switch back to 🔗 view.")
self .log_signal .emit ("INTERNAL: _filter_links_log - Placeholder message emitted.")
return
else :
# Simplified logic: Clear the log and re-trigger the display process
# The main display logic is now fully handled by _display_and_schedule_next
if self .main_log_output :self .main_log_output .clear ()
self._current_link_post_title = None # Reset the title tracking for the new display pass
self .log_signal .emit ("INTERNAL: _filter_links_log - In links view branch. About to clear.")
if self .main_log_output :self .main_log_output .clear ()
self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for links view.")
current_title_for_display =None
any_links_displayed_this_call =False
separator_html ="<br>"+"-"*45 +"<br>"
for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
matches_search =(not search_term or
search_term in link_text .lower ()or
search_term in link_url .lower ()or
search_term in platform .lower ()or
(decryption_key and search_term in decryption_key .lower ()))
if not matches_search :
continue
any_links_displayed_this_call =True
if post_title !=current_title_for_display :
if current_title_for_display is not None :
if self .main_log_output :self .main_log_output .insertHtml (separator_html )
title_html =f'<b style="color: #87CEEB;">{html .escape (post_title )}</b><br>'
if self .main_log_output :self .main_log_output .insertHtml (title_html )
current_title_for_display =post_title
max_link_text_len =50
display_text =(link_text [:max_link_text_len ].strip ()+"..."if len (link_text )>max_link_text_len else link_text .strip ())
plain_link_info_line =f"{display_text } - {link_url } - {platform }"
if decryption_key :
plain_link_info_line +=f" (Decryption Key: {decryption_key })"
if self .main_log_output :
self .main_log_output .append (plain_link_info_line )
if any_links_displayed_this_call :
if self .main_log_output :self .main_log_output .append ("")
elif not search_term and self .main_log_output :
self .log_signal .emit (" (No links extracted yet or all filtered out in links view)")
# Create a new temporary queue containing only the links that match the search term
filtered_link_queue = deque()
for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
matches_search =(not search_term or
search_term in link_text .lower ()or
search_term in link_url .lower ()or
search_term in platform .lower ()or
(decryption_key and search_term in decryption_key .lower ()))
if matches_search :
filtered_link_queue.append((post_title, link_text, link_url, platform, decryption_key))
if not filtered_link_queue:
self .log_signal .emit (" (No links extracted yet or all filtered out in links view)")
else:
self.external_link_queue.clear()
self.external_link_queue.extend(filtered_link_queue)
self._try_process_next_external_link()
if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ())
def _display_and_schedule_next (self ,link_data ):
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode :
if post_title != self._current_link_post_title:
if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html)
title_html = f'<p style="font-size: 11pt; color: #87CEEB; margin-top: 2px; margin-bottom: 2px;"><b>{html.escape(post_title)}</b></p>'
self.log_signal.emit(HTML_PREFIX + title_html)
self._current_link_post_title = post_title
# Use the "smarter" logic to decide what text to show for the link
cleaned_link_text = link_text.strip()
display_text = ""
if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url:
display_text = cleaned_link_text
else:
try:
path = urlparse(link_url).path
filename = os.path.basename(path)
if filename:
display_text = filename
except Exception:
pass
if not display_text:
display_text = link_url
# Truncate long display text
if len(display_text) > 50:
display_text = display_text[:50].strip() + "..."
# --- NEW: Format the output as requested ---
platform_display = platform.capitalize()
plain_link_info_line = f" {platform_display} - {link_url} - ({display_text})"
if decryption_key:
plain_link_info_line += f" (Decryption Key: {decryption_key})"
self.main_log_output.append(plain_link_info_line)
elif self .show_external_links :
# This part for the secondary log remains unchanged
separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}"
if decryption_key:
formatted_link_info += f" (Decryption Key: {decryption_key})"
self._append_to_external_log(formatted_link_info, separator)
self ._is_processing_external_link_queue =False
self ._try_process_next_external_link ()
def _export_links_to_file (self ):
if not (self .radio_only_links and self .radio_only_links .isChecked ()):
@@ -2295,7 +2438,10 @@ class DownloaderApp (QWidget ):
_ ,_ ,post_id =extract_post_info (url_text .strip ())
is_single_post_url =bool (post_id )
subfolders_enabled =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False
subfolders_by_known_txt_enabled = getattr(self, 'use_subfolders_checkbox', None) and self.use_subfolders_checkbox.isChecked()
subfolder_per_post_enabled = getattr(self, 'use_subfolder_per_post_checkbox', None) and self.use_subfolder_per_post_checkbox.isChecked()
any_subfolder_option_enabled = subfolders_by_known_txt_enabled or subfolder_per_post_enabled
not_only_links_or_archives_mode =not (
(self .radio_only_links and self .radio_only_links .isChecked ())or
@@ -2303,7 +2449,7 @@ class DownloaderApp (QWidget ):
(hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ())
)
should_show_custom_folder =is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode
should_show_custom_folder =is_single_post_url and any_subfolder_option_enabled and not_only_links_or_archives_mode
if self .custom_folder_widget :
self .custom_folder_widget .setVisible (should_show_custom_folder )
@@ -2383,7 +2529,7 @@ class DownloaderApp (QWidget ):
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_title_text","Name: Post Title"))
elif self .manga_filename_style ==STYLE_ORIGINAL_NAME :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_original_file_text","Name: Original File"))
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_original_text","Date + Original"))
elif self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_title_global_num_text","Name: Title+G.Num"))
@@ -2400,7 +2546,6 @@ class DownloaderApp (QWidget ):
else :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_unknown_text","Name: Unknown Style"))
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).")
def _toggle_manga_filename_style (self ):
@@ -2516,8 +2661,7 @@ class DownloaderApp (QWidget ):
show_date_prefix_input =(
manga_mode_effectively_on and
(current_filename_style ==STYLE_DATE_BASED or
current_filename_style ==STYLE_ORIGINAL_NAME )and
(current_filename_style ==STYLE_DATE_BASED) and
not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )
)
if hasattr (self ,'manga_date_prefix_input'):
@@ -2607,6 +2751,12 @@ class DownloaderApp (QWidget ):
self .file_progress_label .setText ("")
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False):
if self.active_update_profile:
if not self.new_posts_for_update:
return self._check_for_updates()
else:
return self._start_confirmed_update_download()
self.is_finishing = False
self.downloaded_hash_counts.clear()
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER
@@ -2724,6 +2874,52 @@ class DownloaderApp (QWidget ):
return True
self.cancellation_message_logged_this_session = False
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
# Read the setting at the start of the download
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
profile_processed_ids = set() # Default to an empty set
if self.save_creator_json_enabled_this_session:
# --- CREATOR PROFILE LOGIC ---
creator_name_for_profile = None
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
else:
creator_key = (service.lower(), str(user_id))
creator_name_for_profile = self.creator_name_cache.get(creator_key)
if not creator_name_for_profile:
creator_name_for_profile = f"{service}_{user_id}"
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
# Get all current UI settings and add them to the profile
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
creator_profile_data['settings'] = current_settings
creator_profile_data.setdefault('creator_url', [])
if api_url not in creator_profile_data['creator_url']:
creator_profile_data['creator_url'].append(api_url)
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
# --- END OF PROFILE LOGIC ---
# The rest of this logic runs regardless, but uses the profile data if it was loaded
session_processed_ids = set(processed_post_ids_for_restore)
combined_processed_ids = session_processed_ids.union(profile_processed_ids)
processed_post_ids_for_this_run = list(combined_processed_ids)
use_subfolders = self.use_subfolders_checkbox.isChecked()
use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked()
compress_images = self.compress_images_checkbox.isChecked()
@@ -2831,15 +3027,6 @@ class DownloaderApp (QWidget ):
if backend_filter_mode == 'audio':
effective_skip_zip = self.skip_zip_checkbox.isChecked()
if not api_url:
QMessageBox.critical(self, "Input Error", "URL is required.")
return False
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
creator_folder_ignore_words_for_run = None
is_full_creator_download = not post_id_from_url
@@ -3187,7 +3374,7 @@ class DownloaderApp (QWidget ):
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'skip_current_file_flag': None,
'processed_post_ids': processed_post_ids_for_restore,
'processed_post_ids': processed_post_ids_for_this_run,
'start_offset': start_offset_for_restore,
}
@@ -3232,6 +3419,7 @@ class DownloaderApp (QWidget ):
self.is_paused = False
return True
def restore_download(self):
"""Initiates the download restoration process."""
if self._is_download_active():
@@ -3618,7 +3806,10 @@ class DownloaderApp (QWidget ):
if permanent:
self.permanently_failed_files_for_dialog.extend(permanent)
self._update_error_button_count()
if history_data: self._add_to_history_candidates(history_data)
if history_data:
# This single call now correctly handles both history and profile saving.
self._add_to_history_candidates(history_data)
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
@@ -3667,13 +3858,28 @@ class DownloaderApp (QWidget ):
create_single_pdf_from_content(sorted_content, filepath, font_path, logger=self.log_signal.emit)
self.log_signal.emit("="*40)
def _add_to_history_candidates (self ,history_data ):
"""Adds processed post data to the history candidates list."""
if history_data and len (self .download_history_candidates )<8 :
history_data ['download_date_timestamp']=time .time ()
creator_key =(history_data .get ('service','').lower (),str (history_data .get ('user_id','')))
history_data ['creator_name']=self .creator_name_cache .get (creator_key ,history_data .get ('user_id','Unknown'))
self .download_history_candidates .append (history_data )
def _add_to_history_candidates(self, history_data):
"""Adds processed post data to the history candidates list and updates the creator profile."""
if self.save_creator_json_enabled_this_session:
post_id = history_data.get('post_id')
service = history_data.get('service')
user_id = history_data.get('user_id')
if post_id and service and user_id:
creator_key = (service.lower(), str(user_id))
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{user_id}")
# Load the profile data before using it to prevent NameError
profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(creator_name, profile_data, self.session_file_path)
if history_data and len(self.download_history_candidates) < 8:
history_data['download_date_timestamp'] = time.time()
creator_key = (history_data.get('service','').lower(), str(history_data.get('user_id','')))
history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown'))
self.download_history_candidates.append(history_data)
def _finalize_download_history (self ):
"""Processes candidates and selects the final 3 history entries.
@@ -3864,8 +4070,8 @@ class DownloaderApp (QWidget ):
if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .clear ()
self .character_search_input .clear ();self .thread_count_input .setText ("4");self .radio_all .setChecked (True );
self .skip_zip_checkbox .setChecked (True );self .download_thumbnails_checkbox .setChecked (False );
self .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (True );
self .use_subfolder_per_post_checkbox .setChecked (False );self .use_multithreading_checkbox .setChecked (True );
self .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (False );
self .use_subfolder_per_post_checkbox .setChecked (True );self .use_multithreading_checkbox .setChecked (True );
if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False )
if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setChecked (False )
self .external_links_checkbox .setChecked (False )
@@ -4121,6 +4327,7 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(True)
self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False
self.active_update_profile = None
def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is checked."""
@@ -4372,55 +4579,40 @@ class DownloaderApp (QWidget ):
if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
def reset_application_state(self):
if self._is_download_active():
if self.download_thread and self.download_thread.isRunning():
self.log_signal.emit("⚠️ Cancelling active download thread for reset...")
self.cancellation_event.set()
self.download_thread.requestInterruption()
self.download_thread.wait(3000)
if self.download_thread.isRunning():
self.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.")
self.download_thread.deleteLater()
self.download_thread = None
if self.thread_pool:
self.log_signal.emit(" Shutting down thread pool for reset...")
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.thread_pool = None
self.active_futures = []
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.log_signal.emit(" Cancelling external link download thread for reset...")
self.external_link_download_thread.cancel()
self.external_link_download_thread.wait(3000)
self.external_link_download_thread.deleteLater()
self.external_link_download_thread = None
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Shutting down retry thread pool for reset...")
self.retry_thread_pool.shutdown(wait=True)
self.retry_thread_pool = None
if hasattr(self, 'active_retry_futures'):
self.active_retry_futures.clear()
if hasattr(self, 'active_retry_futures_map'):
self.active_retry_futures_map.clear()
self.cancellation_event.clear()
if self.pause_event:
self.pause_event.clear()
self.is_paused = False
self.log_signal.emit("🔄 Resetting application state to defaults...")
# --- MODIFIED PART: Signal all threads to stop first, but do not wait ---
if self._is_download_active():
self.log_signal.emit(" Cancelling all active background tasks for reset...")
self.cancellation_event.set() # Signal all threads to stop
# Initiate non-blocking shutdowns
if self.download_thread and self.download_thread.isRunning():
self.download_thread.requestInterruption()
if self.thread_pool:
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.thread_pool = None
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.external_link_download_thread.cancel()
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
self.retry_thread_pool = None
self.cancellation_event.clear() # Reset the event for the next run
# --- END OF MODIFIED PART ---
if self.pause_event:
self.pause_event.clear()
self.is_paused = False
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._clear_session_file()
self._reset_ui_to_defaults()
self._load_saved_download_location()
self.main_log_output.clear()
self.external_log_output.clear()
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._reset_ui_to_defaults()
self._load_saved_download_location()
self.main_log_output.clear()
self.external_log_output.clear()
if self.missed_character_log_output:
self.missed_character_log_output.clear()
self.current_log_view = 'progress'
if self.log_view_stack:
self.log_view_stack.setCurrentIndex(0)
@@ -4448,7 +4640,7 @@ class DownloaderApp (QWidget ):
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
self.mega_download_log_preserved_once = False
self.permanently_failed_files_for_dialog.clear()
self._update_error_button_count()
self._update_error_button_count()
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION
self._update_favorite_scope_button_text()
self.retryable_failed_files_info.clear()
@@ -4463,13 +4655,14 @@ class DownloaderApp (QWidget ):
self.is_fetcher_thread_running = False
self.interrupted_session_data = None
self.is_restore_pending = False
self.active_update_profile = None
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
self.settings.sync()
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
self.set_ui_enabled(True)
self.log_signal.emit("✅ Application fully reset. Ready for new download.")
self.is_processing_favorites_queue = False
@@ -4477,7 +4670,7 @@ class DownloaderApp (QWidget ):
self.favorite_download_queue.clear()
self.interrupted_session_data = None
self.is_restore_pending = False
self.last_link_input_text_for_queue_sync = ""
self.last_link_input_text_for_queue_sync = ""
def _reset_ui_to_defaults(self):
"""Resets all UI elements and relevant state to their default values."""
@@ -4498,8 +4691,8 @@ class DownloaderApp (QWidget ):
self.skip_zip_checkbox.setChecked(True)
self.download_thumbnails_checkbox.setChecked(False)
self.compress_images_checkbox.setChecked(False)
self.use_subfolders_checkbox.setChecked(True)
self.use_subfolder_per_post_checkbox.setChecked(False)
self.use_subfolders_checkbox.setChecked(False)
self.use_subfolder_per_post_checkbox.setChecked(True)
self.use_multithreading_checkbox.setChecked(True)
if self.favorite_mode_checkbox:
self.favorite_mode_checkbox.setChecked(False)
@@ -4773,6 +4966,144 @@ class DownloaderApp (QWidget ):
self ._update_favorite_scope_button_text ()
self .log_signal .emit (f" Favorite download scope changed to: '{self .favorite_download_scope }'")
def _check_for_updates(self):
"""Phase 1 of Update: Fetches all posts, compares, and prompts the user for confirmation."""
self.log_signal.emit("🔄 Checking for updates...")
update_url = self.active_update_profile['creator_url'][0]
processed_ids_from_profile = set(self.active_update_profile['processed_post_ids'])
self.log_signal.emit(f" Checking URL: {update_url}")
self.set_ui_enabled(False)
self.progress_label.setText(self._tr("progress_fetching_all_posts", "Progress: Fetching all post pages..."))
QCoreApplication.processEvents()
try:
post_generator = download_from_api(
api_url_input=update_url,
logger=lambda msg: None, # Suppress noisy logs during check
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
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,
processed_post_ids=processed_ids_from_profile
)
all_posts_from_api = [post for batch in post_generator for post in batch]
except Exception as e:
self.log_signal.emit(f"❌ Failed to fetch posts during update check: {e}")
self.download_finished(0, 0, False, [])
return
self.log_signal.emit(f" Fetched a total of {len(all_posts_from_api)} posts from the server.")
self.new_posts_for_update = [post for post in all_posts_from_api if post.get('id') not in processed_ids_from_profile]
if not self.new_posts_for_update:
self.log_signal.emit("✅ Creator is up to date! No new posts found.")
QMessageBox.information(self, "Up to Date", "No new posts were found for this creator.")
self._clear_update_selection()
self.set_ui_enabled(True)
self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle"))
return
self.log_signal.emit(f" Found {len(self.new_posts_for_update)} new post(s). Waiting for user confirmation to download.")
self.progress_label.setText(f"Found {len(self.new_posts_for_update)} new post(s). Ready to download.")
self._update_button_states_and_connections()
def _start_confirmed_update_download(self):
"""Phase 2 of Update: Starts the download of posts found during the check."""
self.log_signal.emit(f"✅ User confirmed. Starting download for {len(self.new_posts_for_update)} new post(s).")
self.main_log_output.clear()
from src.config.constants import FOLDER_NAME_STOP_WORDS
update_url = self.active_update_profile['creator_url'][0]
service, user_id, _ = extract_post_info(update_url)
# --- FIX: Use the BASE download path, not the creator-specific one ---
# Get the base path from the UI (e.g., "E:/Kemono"). The worker will create subfolders inside this.
base_download_dir_from_ui = self.dir_input.text().strip()
self.log_signal.emit(f" Update session will save to base folder: {base_download_dir_from_ui}")
# --- END FIX ---
raw_character_filters_text = self.character_input.text().strip()
parsed_character_filter_objects = self._parse_character_filters(raw_character_filters_text)
# --- FIX: Set paths to mimic a normal download, allowing the worker to create subfolders ---
# 'download_root' is the base directory.
# 'override_output_dir' is None, which allows the worker to use its own folder logic.
args_template = {
'api_url_input': update_url,
'download_root': base_download_dir_from_ui, # Corrected: Use the BASE path
'override_output_dir': None, # Corrected: Set to None to allow subfolder logic
'known_names': list(KNOWN_NAMES),
'filter_character_list': parsed_character_filter_objects,
'emitter': self.worker_to_gui_queue,
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
'filter_mode': self.get_filter_mode(),
'skip_zip': self.skip_zip_checkbox.isChecked(),
'use_subfolders': self.use_subfolders_checkbox.isChecked(),
'use_post_subfolders': self.use_subfolder_per_post_checkbox.isChecked(),
'target_post_id_from_initial_url': None,
'custom_folder_name': self.custom_folder_input.text().strip(),
'compress_images': self.compress_images_checkbox.isChecked(),
'download_thumbnails': self.download_thumbnails_checkbox.isChecked(),
'service': service, 'user_id': user_id, 'pause_event': self.pause_event,
'cancellation_event': self.cancellation_event,
'downloaded_files': self.downloaded_files,
'downloaded_file_hashes': self.downloaded_file_hashes,
'downloaded_files_lock': self.downloaded_files_lock,
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
'skip_words_list': [word.strip().lower() for word in self.skip_words_input.text().strip().split(',') if word.strip()],
'skip_words_scope': self.get_skip_words_scope(),
'show_external_links': self.external_links_checkbox.isChecked(),
'extract_links_only': self.radio_only_links.isChecked(),
'skip_current_file_flag': None,
'manga_mode_active': self.manga_mode_checkbox.isChecked(),
'manga_filename_style': self.manga_filename_style,
'char_filter_scope': self.get_char_filter_scope(),
'remove_from_filename_words_list': [word.strip() for word in self.remove_from_filename_input.text().strip().split(',') if word.strip()],
'allow_multipart_download': self.allow_multipart_download_setting,
'cookie_text': self.cookie_text_input.text(),
'use_cookie': self.use_cookie_checkbox.isChecked(),
'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir,
'manga_date_prefix': self.manga_date_prefix_input.text().strip(),
'manga_date_file_counter_ref': None,
'scan_content_for_images': self.scan_content_images_checkbox.isChecked(),
'creator_download_folder_ignore_words': None,
'manga_global_file_counter_ref': None,
'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked(),
'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked(),
'keep_duplicates_mode': self.keep_duplicates_mode,
'keep_duplicates_limit': self.keep_duplicates_limit,
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'session_file_path': self.session_file_path,
'session_lock': self.session_lock,
'text_only_scope': self.more_filter_scope,
'text_export_format': self.text_export_format,
'single_pdf_mode': self.single_pdf_setting,
'project_root_dir': self.app_base_dir,
'processed_post_ids': list(self.active_update_profile['processed_post_ids'])
}
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='UpdateWorker_')
self.total_posts_to_process = len(self.new_posts_for_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:]
for post_data in self.new_posts_for_update:
self._submit_post_to_worker_pool(
post_data, args_template, 1, self.worker_to_gui_queue, ppw_expected_keys, {}
)
return True
def _show_empty_popup (self ):
"""Creates and shows the empty popup dialog."""
if self.is_restore_pending:
@@ -4782,7 +5113,21 @@ class DownloaderApp (QWidget ):
return
dialog = EmptyPopupDialog(self.app_base_dir, self)
if dialog.exec_() == QDialog.Accepted:
if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
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.")
self.log_signal.emit(f" Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
self._update_button_states_and_connections()
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None
self.favorite_download_queue.clear()
for creator_data in dialog.selected_creators_for_queue:

View File

@@ -239,16 +239,23 @@ def setup_ui(main_app):
checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(True)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- REORDERED CHECKBOXES ---
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
main_app.use_subfolder_per_post_checkbox.setChecked(True)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(False)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- END REORDER ---
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit()
@@ -380,7 +387,7 @@ def setup_ui(main_app):
main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False)
log_title_layout.addWidget(main_app.link_search_input)
main_app.link_search_button = QPushButton("🔍")
main_app.link_search_button = QPushButton("<EFBFBD>")
main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button)