mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Commi
This commit is contained in:
@@ -39,6 +39,7 @@ CHAR_SCOPE_COMMENTS = "comments"
|
||||
FILE_DOWNLOAD_STATUS_SUCCESS = "success"
|
||||
FILE_DOWNLOAD_STATUS_SKIPPED = "skipped"
|
||||
FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later"
|
||||
FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION = "failed_permanent_session" # New status
|
||||
fastapi_app = None
|
||||
KNOWN_NAMES = [] # This will now store dicts: {'name': str, 'is_group': bool, 'aliases': list[str]}
|
||||
MIN_SIZE_FOR_MULTIPART_DOWNLOAD = 10 * 1024 * 1024 # 10 MB - Stays the same
|
||||
@@ -923,8 +924,20 @@ class PostProcessorWorker:
|
||||
'manga_mode_active_for_file': self.manga_mode_active, # Store context
|
||||
'manga_filename_style_for_file': self.manga_filename_style, # Store context
|
||||
}
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None # Generic failure
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, retry_later_details # type: ignore
|
||||
else: # Other non-successful download attempts
|
||||
self.logger(f" Marking '{api_original_filename}' as permanently failed for this session.")
|
||||
permanent_failure_details = {
|
||||
'file_info': file_info,
|
||||
'target_folder_path': target_folder_path,
|
||||
'headers': headers, # Original headers
|
||||
'original_post_id_for_log': original_post_id_for_log,
|
||||
'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post,
|
||||
'num_files_in_this_post': num_files_in_this_post,
|
||||
'forced_filename_override': filename_to_save_in_main_path, # The name it was trying to save as
|
||||
}
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
|
||||
if self._check_pause(f"Post-download hash check for '{api_original_filename}'"): return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
with self.downloaded_file_hashes_lock:
|
||||
if calculated_file_hash in self.downloaded_file_hashes:
|
||||
@@ -1021,11 +1034,12 @@ class PostProcessorWorker:
|
||||
file_content_bytes.close()
|
||||
except Exception: pass # Ignore errors on close if already handled
|
||||
def process(self):
|
||||
if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"): return 0,0,[], []
|
||||
if self.check_cancel(): return 0, 0, [], []
|
||||
if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"): return 0,0,[], [], []
|
||||
if self.check_cancel(): return 0, 0, [], [], []
|
||||
current_character_filters = self._get_current_character_filters()
|
||||
kept_original_filenames_for_log = []
|
||||
retryable_failures_this_post = [] # New list to store retryable failure details
|
||||
permanent_failures_this_post = [] # New list for permanent failures
|
||||
total_downloaded_this_post = 0
|
||||
total_skipped_this_post = 0
|
||||
parsed_api_url = urlparse(self.api_url_input)
|
||||
@@ -1148,28 +1162,28 @@ class PostProcessorWorker:
|
||||
if current_character_filters: # Check if any filters are defined
|
||||
if self.char_filter_scope == CHAR_SCOPE_TITLE and not post_is_candidate_by_title_char_match:
|
||||
self.logger(f" -> Skip Post (Scope: Title - No Char Match): Title '{post_title[:50]}' does not match character filters.")
|
||||
self._emit_signal('missed_character_post', post_title, "No title match for character filter")
|
||||
return 0, num_potential_files_in_post, [], []
|
||||
self._emit_signal('missed_character_post', post_title, "No title match for character filter") # type: ignore
|
||||
return 0, num_potential_files_in_post, [], [], []
|
||||
if self.char_filter_scope == CHAR_SCOPE_COMMENTS and \
|
||||
not post_is_candidate_by_file_char_match_in_comment_scope and \
|
||||
not post_is_candidate_by_comment_char_match: # MODIFIED: Check both file and comment match flags
|
||||
self.logger(f" -> Skip Post (Scope: Comments - No Char Match in Comments): Post ID '{post_id}', Title '{post_title[:50]}...'")
|
||||
if self.emitter and hasattr(self.emitter, 'missed_character_post_signal'): # Check emitter
|
||||
self._emit_signal('missed_character_post', post_title, "No character match in files or comments (Comments scope)")
|
||||
return 0, num_potential_files_in_post, [], []
|
||||
self._emit_signal('missed_character_post', post_title, "No character match in files or comments (Comments scope)") # type: ignore
|
||||
return 0, num_potential_files_in_post, [], [], []
|
||||
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
|
||||
if self._check_pause(f"Skip words (post title) for post {post_id}"): return 0, num_potential_files_in_post, [], []
|
||||
post_title_lower = post_title.lower()
|
||||
for skip_word in self.skip_words_list:
|
||||
if skip_word.lower() in post_title_lower:
|
||||
self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'. Scope: {self.skip_words_scope}")
|
||||
return 0, num_potential_files_in_post, [], []
|
||||
return 0, num_potential_files_in_post, [], [], []
|
||||
if not self.extract_links_only and self.manga_mode_active and current_character_filters and \
|
||||
(self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and \
|
||||
not post_is_candidate_by_title_char_match:
|
||||
self.logger(f" -> Skip Post (Manga Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.")
|
||||
self._emit_signal('missed_character_post', post_title, "Manga Mode: No title match for character filter (Title/Both scope)")
|
||||
return 0, num_potential_files_in_post, [], []
|
||||
self._emit_signal('missed_character_post', post_title, "Manga Mode: No title match for character filter (Title/Both scope)") # type: ignore
|
||||
return 0, num_potential_files_in_post, [], [], []
|
||||
if not isinstance(post_attachments, list):
|
||||
self.logger(f"⚠️ Corrupt attachment data for post {post_id} (expected list, got {type(post_attachments)}). Skipping attachments.")
|
||||
post_attachments = []
|
||||
@@ -1205,13 +1219,13 @@ class PostProcessorWorker:
|
||||
for folder_name_to_check in base_folder_names_for_post_content: # type: ignore
|
||||
if not folder_name_to_check: continue
|
||||
if any(skip_word.lower() in folder_name_to_check.lower() for skip_word in self.skip_words_list):
|
||||
matched_skip = next((sw for sw in self.skip_words_list if sw.lower() in folder_name_to_check.lower()), "unknown_skip_word")
|
||||
matched_skip = next((sw for sw in self.skip_words_list if sw.lower() in folder_name_to_check.lower()), "unknown_skip_word") # type: ignore
|
||||
self.logger(f" -> Skip Post (Folder Keyword): Potential folder '{folder_name_to_check}' contains '{matched_skip}'.")
|
||||
return 0, num_potential_files_in_post, [], []
|
||||
return 0, num_potential_files_in_post, [], [], []
|
||||
if (self.show_external_links or self.extract_links_only) and post_content_html: # type: ignore
|
||||
if self._check_pause(f"External link extraction for post {post_id}"): return 0, num_potential_files_in_post, [], []
|
||||
try:
|
||||
mega_key_pattern = re.compile(r'\b([a-zA-Z0-9_-]{43}|[a-zA-Z0-9_-]{22})\b')
|
||||
mega_key_pattern = re.compile(r'\b([a-zA-Z0-9_-]{43}|[a-zA-Z0-9_-]{22})\b') # type: ignore
|
||||
unique_links_data = {}
|
||||
for match in link_pattern.finditer(post_content_html):
|
||||
link_url = match.group(1).strip()
|
||||
@@ -1231,14 +1245,14 @@ class PostProcessorWorker:
|
||||
decryption_key_found = ""
|
||||
if platform == 'mega':
|
||||
parsed_mega_url = urlparse(link_url)
|
||||
if parsed_mega_url.fragment:
|
||||
if parsed_mega_url.fragment: # type: ignore
|
||||
potential_key_from_fragment = parsed_mega_url.fragment.split('!')[-1] # Handle cases like #!key or #key
|
||||
if mega_key_pattern.fullmatch(potential_key_from_fragment):
|
||||
decryption_key_found = potential_key_from_fragment
|
||||
|
||||
if not decryption_key_found and link_text:
|
||||
key_match_in_text = mega_key_pattern.search(link_text)
|
||||
if key_match_in_text:
|
||||
if key_match_in_text: # type: ignore
|
||||
decryption_key_found = key_match_in_text.group(1)
|
||||
if not decryption_key_found and self.extract_links_only and post_content_html:
|
||||
key_match_in_content = mega_key_pattern.search(strip_html_tags(post_content_html)) # Search cleaned content
|
||||
@@ -1246,12 +1260,12 @@ class PostProcessorWorker:
|
||||
decryption_key_found = key_match_in_content.group(1)
|
||||
if platform not in scraped_platforms:
|
||||
self._emit_signal('external_link', post_title, link_text, link_url, platform, decryption_key_found or "")
|
||||
links_emitted_count +=1
|
||||
links_emitted_count +=1 # type: ignore
|
||||
if links_emitted_count > 0: self.logger(f" 🔗 Found {links_emitted_count} potential external link(s) in post content.")
|
||||
except Exception as e: self.logger(f"⚠️ Error parsing post content for links: {e}\n{traceback.format_exc(limit=2)}")
|
||||
if self.extract_links_only:
|
||||
self.logger(f" Extract Links Only mode: Finished processing post {post_id} for links.")
|
||||
return 0, 0, [], []
|
||||
return 0, 0, [], [], []
|
||||
all_files_from_post_api = []
|
||||
api_file_domain = urlparse(self.api_url_input).netloc
|
||||
if not api_file_domain or not any(d in api_file_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
|
||||
@@ -1338,13 +1352,13 @@ class PostProcessorWorker:
|
||||
all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo.get('_from_content_scan')]
|
||||
if not all_files_from_post_api:
|
||||
self.logger(f" -> No images found via content scan for post {post_id} in this combined mode.")
|
||||
return 0, 0, [], [] # No files to download for this post
|
||||
return 0, 0, [], [], [] # No files to download for this post
|
||||
else:
|
||||
self.logger(f" Mode: 'Download Thumbnails Only' active. Filtering for API thumbnails for post {post_id}.")
|
||||
all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo.get('_is_thumbnail')]
|
||||
if not all_files_from_post_api:
|
||||
self.logger(f" -> No API image thumbnails found for post {post_id} in thumbnail-only mode.")
|
||||
return 0, 0, [], [] # No files to download for this post
|
||||
return 0, 0, [], [], [] # No files to download for this post
|
||||
if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED:
|
||||
def natural_sort_key_for_files(file_api_info):
|
||||
name = file_api_info.get('_original_name_for_log', '').lower()
|
||||
@@ -1353,7 +1367,7 @@ class PostProcessorWorker:
|
||||
self.logger(f" Manga Date Mode: Sorted {len(all_files_from_post_api)} files within post {post_id} by original name for sequential numbering.")
|
||||
if not all_files_from_post_api:
|
||||
self.logger(f" No files found to download for post {post_id}.")
|
||||
return 0, 0, [], []
|
||||
return 0, 0, [], [], []
|
||||
files_to_download_info_list = []
|
||||
processed_original_filenames_in_this_post = set()
|
||||
for file_info in all_files_from_post_api:
|
||||
@@ -1367,7 +1381,7 @@ class PostProcessorWorker:
|
||||
processed_original_filenames_in_this_post.add(current_api_original_filename)
|
||||
if not files_to_download_info_list:
|
||||
self.logger(f" All files for post {post_id} were duplicate original names or skipped earlier.")
|
||||
return 0, total_skipped_this_post, [], []
|
||||
return 0, total_skipped_this_post, [], [], []
|
||||
num_files_in_this_post_for_naming = len(files_to_download_info_list)
|
||||
self.logger(f" Identified {num_files_in_this_post_for_naming} unique original file(s) for potential download from post {post_id}.")
|
||||
with ThreadPoolExecutor(max_workers=self.num_file_threads, thread_name_prefix=f'P{post_id}File_') as file_pool:
|
||||
@@ -1474,14 +1488,16 @@ class PostProcessorWorker:
|
||||
if not f_to_cancel.done():
|
||||
f_to_cancel.cancel()
|
||||
break
|
||||
try:
|
||||
dl_count, skip_count, actual_filename_saved, original_kept_flag, status, retry_details = future.result()
|
||||
try: # type: ignore
|
||||
dl_count, skip_count, actual_filename_saved, original_kept_flag, status, details_for_dialog_or_retry = future.result()
|
||||
total_downloaded_this_post += dl_count
|
||||
total_skipped_this_post += skip_count
|
||||
if original_kept_flag and dl_count > 0 and actual_filename_saved:
|
||||
kept_original_filenames_for_log.append(actual_filename_saved)
|
||||
if status == FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER and retry_details:
|
||||
retryable_failures_this_post.append(retry_details)
|
||||
if status == FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER and details_for_dialog_or_retry:
|
||||
retryable_failures_this_post.append(details_for_dialog_or_retry)
|
||||
elif status == FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION and details_for_dialog_or_retry:
|
||||
permanent_failures_this_post.append(details_for_dialog_or_retry)
|
||||
except CancelledError:
|
||||
self.logger(f" File download task for post {post_id} was cancelled.")
|
||||
total_skipped_this_post += 1
|
||||
@@ -1491,16 +1507,17 @@ class PostProcessorWorker:
|
||||
self._emit_signal('file_progress', "", None)
|
||||
if self.check_cancel(): self.logger(f" Post {post_id} processing interrupted/cancelled.");
|
||||
else: self.logger(f" Post {post_id} Summary: Downloaded={total_downloaded_this_post}, Skipped Files={total_skipped_this_post}")
|
||||
return total_downloaded_this_post, total_skipped_this_post, kept_original_filenames_for_log, retryable_failures_this_post
|
||||
return total_downloaded_this_post, total_skipped_this_post, kept_original_filenames_for_log, retryable_failures_this_post, permanent_failures_this_post
|
||||
class DownloadThread(QThread):
|
||||
progress_signal = pyqtSignal(str) # Already QObject, no need to change
|
||||
add_character_prompt_signal = pyqtSignal(str)
|
||||
file_download_status_signal = pyqtSignal(bool)
|
||||
finished_signal = pyqtSignal(int, int, bool, list) # total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list
|
||||
finished_signal = pyqtSignal(int, int, bool, list) # total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list # type: ignore
|
||||
external_link_signal = pyqtSignal(str, str, str, str, str) # post_title, link_text, link_url, platform, decryption_key
|
||||
file_progress_signal = pyqtSignal(str, object)
|
||||
retryable_file_failed_signal = pyqtSignal(list) # New: list of retry_details dicts
|
||||
missed_character_post_signal = pyqtSignal(str, str) # New: post_title, reason
|
||||
permanent_file_failed_signal = pyqtSignal(list) # New: list of permanent failure details
|
||||
def __init__(self, api_url_input, output_dir, known_names_copy,
|
||||
cancellation_event,
|
||||
pause_event, filter_character_list=None, dynamic_character_filter_holder=None, # Added pause_event and holder
|
||||
@@ -1703,13 +1720,15 @@ class DownloadThread(QThread):
|
||||
scan_content_for_images=self.scan_content_for_images, # Pass new flag
|
||||
)
|
||||
try:
|
||||
dl_count, skip_count, kept_originals_this_post, retryable_failures = post_processing_worker.process()
|
||||
dl_count, skip_count, kept_originals_this_post, retryable_failures, permanent_failures = post_processing_worker.process()
|
||||
grand_total_downloaded_files += dl_count
|
||||
grand_total_skipped_files += skip_count
|
||||
if kept_originals_this_post:
|
||||
grand_list_of_kept_original_filenames.extend(kept_originals_this_post)
|
||||
if retryable_failures:
|
||||
self.retryable_file_failed_signal.emit(retryable_failures)
|
||||
if permanent_failures: # Emit new signal for permanent failures
|
||||
self.permanent_file_failed_signal.emit(permanent_failures)
|
||||
except Exception as proc_err:
|
||||
post_id_for_err = individual_post_data.get('id', 'N/A')
|
||||
self.logger(f"❌ Error processing post {post_id_for_err} in DownloadThread: {proc_err}")
|
||||
@@ -1736,6 +1755,7 @@ class DownloadThread(QThread):
|
||||
worker_signals_obj.external_link_signal.disconnect(self.external_link_signal)
|
||||
worker_signals_obj.file_progress_signal.disconnect(self.file_progress_signal)
|
||||
worker_signals_obj.missed_character_post_signal.disconnect(self.missed_character_post_signal)
|
||||
|
||||
except (TypeError, RuntimeError) as e:
|
||||
self.logger(f"ℹ️ Note during DownloadThread signal disconnection: {e}")
|
||||
self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames)
|
||||
|
||||
300
main.py
300
main.py
@@ -234,6 +234,74 @@ class ConfirmAddAllDialog(QDialog):
|
||||
return CONFIRM_ADD_ALL_SKIP_ADDING
|
||||
return self.user_choice
|
||||
|
||||
class ErrorFilesDialog(QDialog):
|
||||
"""Dialog to display files that were skipped due to errors."""
|
||||
retry_selected_signal = pyqtSignal(list) # Signal to emit selected files for retry
|
||||
def __init__(self, error_files_info_list, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Files Skipped Due to Errors")
|
||||
self.setModal(True)
|
||||
self.error_files = error_files_info_list
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
if not self.error_files:
|
||||
info_label = QLabel("No files were recorded as skipped due to errors in the last session or after retries.")
|
||||
main_layout.addWidget(info_label)
|
||||
else:
|
||||
info_label = QLabel(f"The following {len(self.error_files)} file(s) were skipped due to download errors:")
|
||||
info_label.setWordWrap(True)
|
||||
main_layout.addWidget(info_label)
|
||||
|
||||
self.files_list_widget = QListWidget()
|
||||
self.files_list_widget.setSelectionMode(QAbstractItemView.NoSelection) # We use checkboxes
|
||||
for error_info in self.error_files:
|
||||
filename = error_info.get('forced_filename_override', error_info.get('file_info', {}).get('name', 'Unknown Filename'))
|
||||
post_title = error_info.get('post_title', 'Unknown Post')
|
||||
post_id = error_info.get('original_post_id_for_log', 'N/A')
|
||||
item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})"
|
||||
list_item = QListWidgetItem(item_text)
|
||||
list_item.setData(Qt.UserRole, error_info) # Store the original error_info
|
||||
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
|
||||
list_item.setCheckState(Qt.Unchecked)
|
||||
self.files_list_widget.addItem(list_item)
|
||||
main_layout.addWidget(self.files_list_widget)
|
||||
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.select_all_button = QPushButton("Select All")
|
||||
self.select_all_button.clicked.connect(self._select_all_items)
|
||||
buttons_layout.addWidget(self.select_all_button)
|
||||
|
||||
self.retry_button = QPushButton("Retry Selected")
|
||||
self.retry_button.clicked.connect(self._handle_retry_selected)
|
||||
buttons_layout.addWidget(self.retry_button)
|
||||
|
||||
buttons_layout.addStretch(1)
|
||||
self.ok_button = QPushButton("OK")
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
buttons_layout.addWidget(self.ok_button)
|
||||
main_layout.addLayout(buttons_layout)
|
||||
|
||||
self.select_all_button.setEnabled(bool(self.error_files))
|
||||
self.retry_button.setEnabled(bool(self.error_files))
|
||||
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(300)
|
||||
if parent and hasattr(parent, 'get_dark_theme'):
|
||||
self.setStyleSheet(parent.get_dark_theme())
|
||||
self.ok_button.setDefault(True)
|
||||
|
||||
def _select_all_items(self):
|
||||
for i in range(self.files_list_widget.count()):
|
||||
self.files_list_widget.item(i).setCheckState(Qt.Checked)
|
||||
|
||||
def _handle_retry_selected(self):
|
||||
selected_files_for_retry = [self.files_list_widget.item(i).data(Qt.UserRole) for i in range(self.files_list_widget.count()) if self.files_list_widget.item(i).checkState() == Qt.Checked]
|
||||
if selected_files_for_retry:
|
||||
self.retry_selected_signal.emit(selected_files_for_retry)
|
||||
self.accept() # Close dialog after emitting signal
|
||||
else:
|
||||
QMessageBox.information(self, "No Selection", "Please select at least one file to retry.")
|
||||
class FutureSettingsDialog(QDialog):
|
||||
"""A simple dialog as a placeholder for future settings."""
|
||||
def __init__(self, parent_app_ref, parent=None): # parent_app_ref is DownloaderApp
|
||||
@@ -247,7 +315,6 @@ class FutureSettingsDialog(QDialog):
|
||||
label = QLabel("Application Settings:")
|
||||
label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(label)
|
||||
# Theme toggle button
|
||||
self.theme_toggle_button = QPushButton()
|
||||
self._update_theme_toggle_button_text() # Set initial text
|
||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||
@@ -301,20 +368,14 @@ class EmptyPopupDialog(QDialog):
|
||||
self.globally_selected_creators = {} # Key: (service, id), Value: creator_data
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Search bar
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Search creators...")
|
||||
self.search_input.textChanged.connect(self._filter_list)
|
||||
layout.addWidget(self.search_input)
|
||||
|
||||
# List widget for dummy items
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.itemChanged.connect(self._handle_item_check_changed) # Connect signal for check state changes
|
||||
self._load_creators_from_json() # This will load data and call _filter_list for initial population
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
# Buttons at the bottom
|
||||
button_layout = QHBoxLayout()
|
||||
self.add_selected_button = QPushButton("Add Selected")
|
||||
self.add_selected_button.setToolTip(
|
||||
@@ -334,8 +395,6 @@ class EmptyPopupDialog(QDialog):
|
||||
self.scope_button.clicked.connect(self._toggle_scope_mode)
|
||||
button_layout.addWidget(self.scope_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Optional: Apply dark theme if parent has it
|
||||
if parent and hasattr(parent, 'get_dark_theme'):
|
||||
self.setStyleSheet(parent.get_dark_theme())
|
||||
|
||||
@@ -344,11 +403,8 @@ class EmptyPopupDialog(QDialog):
|
||||
self.list_widget.clear() # Clear previous content (like error messages)
|
||||
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# Running as a PyInstaller bundle, creators.json is bundled
|
||||
base_path_for_creators = sys._MEIPASS
|
||||
else:
|
||||
# Running as a script, creators.json is next to main.py
|
||||
# self.app_base_dir is correctly set by DownloaderApp for this case
|
||||
base_path_for_creators = self.app_base_dir
|
||||
creators_file_path = os.path.join(base_path_for_creators, "creators.json")
|
||||
|
||||
@@ -360,7 +416,6 @@ class EmptyPopupDialog(QDialog):
|
||||
try:
|
||||
with open(creators_file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# creators.json has a structure like [ [ {creator1}, {creator2} ] ]
|
||||
if isinstance(data, list) and len(data) > 0 and isinstance(data[0], list):
|
||||
self.all_creators_data = data[0]
|
||||
elif isinstance(data, list) and all(isinstance(item, dict) for item in data): # Handle flat list too
|
||||
@@ -369,11 +424,7 @@ class EmptyPopupDialog(QDialog):
|
||||
self.list_widget.addItem("Error: Invalid format in creators.json.")
|
||||
self.all_creators_data = []
|
||||
return
|
||||
|
||||
# Sort creators by 'favorited' count in descending order
|
||||
# Use .get('favorited', 0) to handle missing keys gracefully, treating them as 0
|
||||
self.all_creators_data.sort(key=lambda c: c.get('favorited', 0), reverse=True)
|
||||
# self.list_widget.clear() # Moved to the top of the method
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.list_widget.addItem("Error: Could not parse creators.json.")
|
||||
@@ -389,26 +440,18 @@ class EmptyPopupDialog(QDialog):
|
||||
self.list_widget.blockSignals(True) # Block itemChanged signal during population
|
||||
self.list_widget.clear()
|
||||
if not creators_to_display and self.search_input.text().strip():
|
||||
|
||||
# Optionally, add a "No results found" item if search is active and no results
|
||||
# self.list_widget.addItem("No creators match your search.")
|
||||
pass # Or just show an empty list
|
||||
elif not creators_to_display:
|
||||
# This case is for when creators.json is empty or initial load results in no items.
|
||||
# Error messages are handled by _load_creators_from_json.
|
||||
pass
|
||||
|
||||
for creator in creators_to_display:
|
||||
creator_name_raw = creator.get('name')
|
||||
# Use "Unknown Creator" if name is None, empty, or only whitespace
|
||||
display_creator_name = creator_name_raw.strip() if isinstance(creator_name_raw, str) and creator_name_raw.strip() else "Unknown Creator"
|
||||
service_display_name = creator.get('service', 'N/A').capitalize()
|
||||
display_text = f"{display_creator_name} ({service_display_name})"
|
||||
item = QListWidgetItem(display_text)
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setData(Qt.UserRole, creator) # Store the whole creator dict
|
||||
|
||||
# Preserve check state based on globally_selected_creators
|
||||
service = creator.get('service')
|
||||
creator_id = creator.get('id')
|
||||
if service is not None and creator_id is not None:
|
||||
@@ -425,43 +468,25 @@ class EmptyPopupDialog(QDialog):
|
||||
def _filter_list(self):
|
||||
"""Filters the list widget based on the search input."""
|
||||
raw_search_input = self.search_input.text()
|
||||
|
||||
# For the initial "empty search" check, use a simple lowercased and stripped version
|
||||
check_search_text_for_empty = raw_search_input.lower().strip() # Used only to check if search is empty
|
||||
|
||||
if not check_search_text_for_empty:
|
||||
# Display initial limited list (top N from sorted all_creators_data)
|
||||
creators_to_show = self.all_creators_data[:self.INITIAL_LOAD_LIMIT]
|
||||
self._populate_list_widget(creators_to_show)
|
||||
else:
|
||||
# Active search: Prepare normalized search terms
|
||||
|
||||
# For case-insensitive search: NFKC normalize, then casefold, then strip.
|
||||
norm_search_casefolded = unicodedata.normalize('NFKC', raw_search_input).casefold().strip()
|
||||
|
||||
# For original case sensitive search: NFKC normalize, then strip.
|
||||
norm_search_original = unicodedata.normalize('NFKC', raw_search_input).strip()
|
||||
|
||||
filtered_creators = []
|
||||
for creator_data in self.all_creators_data:
|
||||
creator_name_raw = creator_data.get('name', '')
|
||||
creator_service_raw = creator_data.get('service', '')
|
||||
|
||||
# Normalize creator name from data
|
||||
norm_creator_name_from_data = unicodedata.normalize('NFKC', creator_name_raw)
|
||||
|
||||
# For case-insensitive match: casefold the normalized creator name
|
||||
norm_creator_name_casefolded = norm_creator_name_from_data.casefold()
|
||||
|
||||
# 1. Case-insensitive match
|
||||
name_match_insensitive = norm_search_casefolded in norm_creator_name_casefolded
|
||||
|
||||
# 2. Original case match (search term must be non-empty after normalization and stripping)
|
||||
name_match_original_case = norm_search_original and norm_search_original in norm_creator_name_from_data
|
||||
|
||||
name_match = name_match_insensitive or name_match_original_case
|
||||
|
||||
# Match against service (normalize service name and use casefolded search term)
|
||||
norm_service_casefolded = unicodedata.normalize('NFKC', creator_service_raw).casefold()
|
||||
service_match = norm_search_casefolded in norm_service_casefolded
|
||||
|
||||
@@ -481,23 +506,18 @@ class EmptyPopupDialog(QDialog):
|
||||
f"Click to toggle between '{self.SCOPE_CHARACTERS}' and '{self.SCOPE_CREATORS}' scopes.\n"
|
||||
f"'{self.SCOPE_CHARACTERS}': (Planned) Downloads into character-named folders directly in the main Download Location (artists mixed).\n"
|
||||
f"'{self.SCOPE_CREATORS}': (Planned) Downloads into artist-named subfolders within the main Download Location, then character folders inside those.")
|
||||
# You can add logic here to react to the mode change if needed in the future
|
||||
|
||||
def _get_domain_for_service(self, service_name):
|
||||
"""Determines the base domain for a given service."""
|
||||
service_lower = service_name.lower()
|
||||
# Common Coomer services
|
||||
if service_lower in ['onlyfans', 'fansly']:
|
||||
return "coomer.su" # Or coomer.party, adjust if needed
|
||||
# Default to Kemono for others
|
||||
return "kemono.su"
|
||||
|
||||
def _handle_add_selected(self):
|
||||
"""Gathers globally selected creators and processes them."""
|
||||
selected_display_names = []
|
||||
self.selected_creators_for_queue.clear() # Clear before populating
|
||||
|
||||
# Iterate over the globally stored selected creators
|
||||
for creator_data in self.globally_selected_creators.values():
|
||||
creator_name = creator_data.get('name')
|
||||
self.selected_creators_for_queue.append(creator_data) # Store the full creator object
|
||||
@@ -507,7 +527,6 @@ class EmptyPopupDialog(QDialog):
|
||||
if selected_display_names:
|
||||
main_app_window = self.parent() # QDialog's parent is the DownloaderApp instance
|
||||
if hasattr(main_app_window, 'link_input'):
|
||||
# Sort display names alphabetically for consistent UI
|
||||
main_app_window.link_input.setText(", ".join(sorted(selected_display_names)))
|
||||
self.accept() # Close the dialog
|
||||
else:
|
||||
@@ -523,7 +542,6 @@ class EmptyPopupDialog(QDialog):
|
||||
creator_id = creator_data.get('id')
|
||||
|
||||
if service is None or creator_id is None:
|
||||
# This should ideally not happen for valid creator entries
|
||||
print(f"Warning: Creator data in list item missing service or id: {creator_data.get('name')}")
|
||||
return
|
||||
|
||||
@@ -537,7 +555,6 @@ class EmptyPopupDialog(QDialog):
|
||||
|
||||
class CookieHelpDialog(QDialog):
|
||||
"""A dialog to explain how to get a cookies.txt file."""
|
||||
# Define constants for user choices
|
||||
CHOICE_PROCEED_WITHOUT_COOKIES = 1
|
||||
CHOICE_CANCEL_DOWNLOAD = 2
|
||||
CHOICE_OK_INFO_ONLY = 3
|
||||
@@ -548,8 +565,6 @@ class CookieHelpDialog(QDialog):
|
||||
self.setModal(True)
|
||||
self.offer_download_without_option = offer_download_without_option
|
||||
self.user_choice = None # Will be set by button actions
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
instruction_text = """
|
||||
@@ -578,8 +593,6 @@ class CookieHelpDialog(QDialog):
|
||||
info_label.setOpenExternalLinks(True)
|
||||
info_label.setWordWrap(True)
|
||||
main_layout.addWidget(info_label)
|
||||
|
||||
# Button layout
|
||||
button_layout = QHBoxLayout()
|
||||
if self.offer_download_without_option:
|
||||
button_layout.addStretch(1) # Push both buttons to the right
|
||||
@@ -742,8 +755,6 @@ class FavoriteArtistsDialog(QDialog):
|
||||
}""")
|
||||
main_layout.addWidget(self.artist_list_widget)
|
||||
self.artist_list_widget.setAlternatingRowColors(True)
|
||||
|
||||
# Initially hide list and search until content is loaded
|
||||
self.search_input.setVisible(False)
|
||||
self.artist_list_widget.setVisible(False)
|
||||
|
||||
@@ -945,8 +956,6 @@ class FavoritePostsFetcherThread(QThread):
|
||||
if not isinstance(posts_data_from_api, list):
|
||||
self.finished.emit([], f"Error: API did not return a list of posts (got {type(posts_data_from_api)}).")
|
||||
return
|
||||
|
||||
# --- This is the creator name fetching logic, moved from FavoritePostsDialog ---
|
||||
all_fetched_posts_temp = []
|
||||
for post_entry in posts_data_from_api:
|
||||
post_id = post_entry.get("id")
|
||||
@@ -962,9 +971,6 @@ class FavoritePostsFetcherThread(QThread):
|
||||
})
|
||||
else:
|
||||
self._logger(f"Warning: Skipping favorite post entry due to missing data: {post_entry}")
|
||||
|
||||
# Creator name fetching logic removed.
|
||||
# Sort by service, then creator_id, then date for consistent grouping
|
||||
all_fetched_posts_temp.sort(key=lambda x: (x.get('service','').lower(), x.get('creator_id','').lower(), (x.get('added_date') or '')), reverse=False)
|
||||
self.finished.emit(all_fetched_posts_temp, None)
|
||||
|
||||
@@ -996,21 +1002,15 @@ class PostListItemWidget(QWidget):
|
||||
def _setup_display_text(self):
|
||||
suffix_plain = self.post_data.get('suffix_for_display', "") # Changed from prefix_for_display
|
||||
title_plain = self.post_data.get('title', 'Untitled Post')
|
||||
|
||||
# Escape them for HTML display
|
||||
escaped_suffix = html.escape(suffix_plain) # Changed from escaped_prefix
|
||||
escaped_title = html.escape(title_plain)
|
||||
|
||||
# Styles
|
||||
p_style_paragraph = "font-size:10.5pt; margin:0; padding:0;" # Base paragraph style (size, margins)
|
||||
title_span_style = "font-weight:bold; color:#E0E0E0;" # Style for the title part (bold, bright white)
|
||||
suffix_span_style = "color:#999999; font-weight:normal; font-size:9.5pt;" # Style for the suffix (dimmer gray, normal weight, slightly smaller)
|
||||
|
||||
if escaped_suffix:
|
||||
# Title part is bold and bright, suffix part is normal weight and dimmer
|
||||
display_html_content = f"<p style='{p_style_paragraph}'><span style='{title_span_style}'>{escaped_title}</span><span style='{suffix_span_style}'>{escaped_suffix}</span></p>"
|
||||
else:
|
||||
# Only title part
|
||||
display_html_content = f"<p style='{p_style_paragraph}'><span style='{title_span_style}'>{escaped_title}</span></p>"
|
||||
|
||||
self.info_label.setText(display_html_content)
|
||||
@@ -1101,12 +1101,9 @@ class FavoritePostsDialog(QDialog):
|
||||
self._logger("Attempting to load creators.json for Favorite Posts Dialog.")
|
||||
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# Running as a PyInstaller bundle, creators.json is bundled
|
||||
base_path_for_creators = sys._MEIPASS
|
||||
self._logger(f" Running bundled. Using _MEIPASS: {base_path_for_creators}")
|
||||
else:
|
||||
# Running as a script, or _MEIPASS not available.
|
||||
# self.parent_app.app_base_dir is correctly set by DownloaderApp.
|
||||
base_path_for_creators = self.parent_app.app_base_dir
|
||||
self._logger(f" Not bundled or _MEIPASS unavailable. Using app_base_dir: {base_path_for_creators}")
|
||||
|
||||
@@ -1134,7 +1131,6 @@ class FavoritePostsDialog(QDialog):
|
||||
name = creator_data.get("name")
|
||||
service = creator_data.get("service")
|
||||
if creator_id and name and service:
|
||||
# Ensure IDs are strings for consistent lookup
|
||||
self.creator_name_cache[(service.lower(), str(creator_id))] = name
|
||||
self._logger(f"Successfully loaded {len(self.creator_name_cache)} creator names from 'creators.json'.")
|
||||
except Exception as e:
|
||||
@@ -1190,8 +1186,6 @@ class FavoritePostsDialog(QDialog):
|
||||
|
||||
|
||||
processed_one_missing_log = False # Flag to log only one missing key example per fetch
|
||||
|
||||
# Add creator name to each post
|
||||
for post_entry in fetched_posts_list:
|
||||
service_from_post = post_entry.get('service', '')
|
||||
creator_id_from_post = post_entry.get('creator_id', '')
|
||||
@@ -1238,28 +1232,20 @@ class FavoritePostsDialog(QDialog):
|
||||
|
||||
for known_entry in self.known_names_list_ref:
|
||||
aliases_to_check = set()
|
||||
# Add all explicit aliases from the known entry
|
||||
for alias_val in known_entry.get("aliases", []):
|
||||
aliases_to_check.add(alias_val)
|
||||
# For non-group entries, the primary name is also a key alias
|
||||
if not known_entry.get("is_group", False):
|
||||
aliases_to_check.add(known_entry["name"])
|
||||
|
||||
# Sort this entry's aliases by length (longest first)
|
||||
# to prioritize more specific aliases within the same known_entry
|
||||
sorted_aliases_for_entry = sorted(list(aliases_to_check), key=len, reverse=True)
|
||||
|
||||
for alias in sorted_aliases_for_entry:
|
||||
alias_lower = alias.lower()
|
||||
if not alias_lower:
|
||||
continue
|
||||
|
||||
# Check for whole word match using regex
|
||||
if re.search(r'\b' + re.escape(alias_lower) + r'\b', title_lower):
|
||||
if len(alias_lower) > longest_match_len:
|
||||
longest_match_len = len(alias_lower)
|
||||
best_match_known_name_primary = known_entry["name"] # Store the primary name
|
||||
# Since aliases for this entry are sorted by length, first match is the best for this entry
|
||||
break # Move to the next known_entry
|
||||
return best_match_known_name_primary
|
||||
|
||||
@@ -1267,8 +1253,6 @@ class FavoritePostsDialog(QDialog):
|
||||
self.post_list_widget.clear()
|
||||
|
||||
source_list_for_grouping = posts_to_display if posts_to_display is not None else self.all_fetched_posts
|
||||
|
||||
# Group posts by (service, creator_id)
|
||||
grouped_posts = {}
|
||||
for post in source_list_for_grouping:
|
||||
service = post.get('service', 'unknown_service')
|
||||
@@ -1285,13 +1269,11 @@ class FavoritePostsDialog(QDialog):
|
||||
for key in sorted_group_keys # type: ignore
|
||||
}
|
||||
for service, creator_id_val in sorted_group_keys:
|
||||
# Resolve creator name for header
|
||||
creator_name_display = self.creator_name_cache.get(
|
||||
(service.lower(), str(creator_id_val)), # Ensure service is lower and id is str for lookup
|
||||
str(creator_id_val) # Fallback to creator_id_val if not found
|
||||
)
|
||||
artist_header_display_text = f"{creator_name_display} ({service.capitalize()} / {creator_id_val})"
|
||||
# Add artist header item
|
||||
artist_header_item = QListWidgetItem(f"🎨 {artist_header_display_text}")
|
||||
artist_header_item.setFlags(Qt.NoItemFlags) # Not selectable, not checkable
|
||||
font = artist_header_item.font()
|
||||
@@ -1300,12 +1282,8 @@ class FavoritePostsDialog(QDialog):
|
||||
artist_header_item.setFont(font)
|
||||
artist_header_item.setForeground(Qt.cyan) # Style for header
|
||||
self.post_list_widget.addItem(artist_header_item)
|
||||
|
||||
# Add post items for this artist
|
||||
for post_data in self.displayable_grouped_posts[(service, creator_id_val)]:
|
||||
post_title_raw = post_data.get('title', 'Untitled Post')
|
||||
|
||||
# Find if a known name is in the title and prepare prefix
|
||||
found_known_name_primary = self._find_best_known_name_match_in_title(post_title_raw)
|
||||
|
||||
plain_text_title_for_list_item = post_title_raw
|
||||
@@ -1331,7 +1309,6 @@ class FavoritePostsDialog(QDialog):
|
||||
|
||||
filtered_posts_to_group = []
|
||||
for post in self.all_fetched_posts:
|
||||
# Check if search text matches post title, creator name, creator ID, or service
|
||||
matches_post_title = search_text in post.get('title', '').lower()
|
||||
matches_creator_name = search_text in post.get('creator_name_resolved', '').lower() # Search resolved name
|
||||
matches_creator_id = search_text in post.get('creator_id', '').lower()
|
||||
@@ -1951,12 +1928,8 @@ class DownloaderApp(QWidget):
|
||||
super().__init__()
|
||||
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
|
||||
if getattr(sys, 'frozen', False):
|
||||
# If the application is run as a bundle (one-file or one-dir),
|
||||
# sys.executable is the path to the executable.
|
||||
# os.path.dirname(sys.executable) gives the directory where the .exe is.
|
||||
self.app_base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
# The application is run as a normal Python script.
|
||||
self.app_base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.config_file = os.path.join(self.app_base_dir, "Known.txt")
|
||||
|
||||
@@ -1972,6 +1945,7 @@ class DownloaderApp(QWidget):
|
||||
self.is_processing_favorites_queue = False
|
||||
self.download_counter = 0
|
||||
self.favorite_download_queue = deque()
|
||||
self.permanently_failed_files_for_dialog = [] # For the error dialog
|
||||
self.last_link_input_text_for_queue_sync = "" # For syncing queue with link_input
|
||||
self.is_fetcher_thread_running = False
|
||||
self.is_processing_favorites_queue = False
|
||||
@@ -2043,11 +2017,8 @@ class DownloaderApp(QWidget):
|
||||
self.already_logged_bold_key_terms = set()
|
||||
self.missed_key_terms_buffer = []
|
||||
self.char_filter_scope_toggle_button = None
|
||||
|
||||
# --- MODIFICATION: Set fixed default scopes, do not load from settings ---
|
||||
self.skip_words_scope = SKIP_SCOPE_POSTS
|
||||
self.char_filter_scope = CHAR_SCOPE_TITLE
|
||||
# --- END MODIFICATION ---
|
||||
self.manga_filename_style = self.settings.value(MANGA_FILENAME_STYLE_KEY, STYLE_POST_TITLE, type=str)
|
||||
self.current_theme = self.settings.value(THEME_KEY, "dark", type=str) # Load theme, default to dark
|
||||
self.allow_multipart_download_setting = False
|
||||
@@ -2099,7 +2070,6 @@ class DownloaderApp(QWidget):
|
||||
self.setStyleSheet("") # Clear stylesheet for default light theme
|
||||
if not initial_load:
|
||||
self.log_signal.emit("🎨 Switched to Light Mode.")
|
||||
# Force style update on children if necessary (usually not needed with setStyleSheet on top level)
|
||||
self.update()
|
||||
|
||||
def _get_tooltip_for_character_input(self):
|
||||
@@ -2183,6 +2153,8 @@ class DownloaderApp(QWidget):
|
||||
self.favorite_mode_posts_button.clicked.connect(self._show_favorite_posts_dialog)
|
||||
if hasattr(self, 'favorite_scope_toggle_button'):
|
||||
self.favorite_scope_toggle_button.clicked.connect(self._cycle_favorite_scope)
|
||||
if hasattr(self, 'error_btn'): # Connect the error button
|
||||
self.error_btn.clicked.connect(self._show_error_files_dialog)
|
||||
|
||||
def _on_character_input_changed_live(self, text):
|
||||
"""
|
||||
@@ -2347,8 +2319,6 @@ class DownloaderApp(QWidget):
|
||||
def closeEvent(self, event):
|
||||
self.save_known_names()
|
||||
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
|
||||
# self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope) # Do not save to persist default
|
||||
# self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope) # Do not save to persist default
|
||||
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
|
||||
self.settings.setValue(COOKIE_TEXT_KEY, self.cookie_text_input.text() if hasattr(self, 'cookie_text_input') else "")
|
||||
self.settings.setValue(SCAN_CONTENT_IMAGES_KEY, self.scan_content_images_checkbox.isChecked() if hasattr(self, 'scan_content_images_checkbox') else False)
|
||||
@@ -2418,8 +2388,6 @@ class DownloaderApp(QWidget):
|
||||
self.link_input.setToolTip("Enter the full URL of a Kemono/Coomer creator's page or a specific post.\nExample (Creator): https://kemono.su/patreon/user/12345\nExample (Post): https://kemono.su/patreon/user/12345/post/98765")
|
||||
self.link_input.textChanged.connect(self.update_custom_folder_visibility)
|
||||
url_input_layout.addWidget(self.link_input, 1)
|
||||
|
||||
# Add the new empty popup button
|
||||
self.empty_popup_button = QPushButton("🎨") # Changed text to emoji
|
||||
self.empty_popup_button.setToolTip(
|
||||
"Open Creator Selection\n\n"
|
||||
@@ -2473,7 +2441,6 @@ class DownloaderApp(QWidget):
|
||||
self.favorite_mode_posts_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
|
||||
self.favorite_scope_toggle_button = QPushButton()
|
||||
# self.favorite_scope_toggle_button.setStyleSheet("padding: 6px 10px;") # Old
|
||||
self.favorite_scope_toggle_button.setStyleSheet("padding: 4px 10px;") # Standardized padding
|
||||
self.favorite_scope_toggle_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
|
||||
@@ -2531,7 +2498,6 @@ class DownloaderApp(QWidget):
|
||||
|
||||
self.char_filter_scope_toggle_button = QPushButton()
|
||||
self._update_char_filter_scope_button_text()
|
||||
# self.char_filter_scope_toggle_button.setStyleSheet("padding: 6px 10px;") # Old
|
||||
self.char_filter_scope_toggle_button.setStyleSheet("padding: 4px 10px;") # Standardized padding
|
||||
self.char_filter_scope_toggle_button.setMinimumWidth(100)
|
||||
char_input_and_button_layout.addWidget(self.char_filter_scope_toggle_button, 1)
|
||||
@@ -2575,7 +2541,6 @@ class DownloaderApp(QWidget):
|
||||
skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
skip_input_and_button_layout.setSpacing(10)
|
||||
self.skip_words_input = QLineEdit()
|
||||
# Updated tooltip for skip_words_input
|
||||
self.skip_words_input.setToolTip(
|
||||
"Enter words, comma-separated, to skip downloading certain content (e.g., WIP, sketch, preview).\n\n"
|
||||
"The 'Scope: [Type]' button next to this input cycles how this filter applies:\n"
|
||||
@@ -2588,7 +2553,6 @@ class DownloaderApp(QWidget):
|
||||
|
||||
self.skip_scope_toggle_button = QPushButton()
|
||||
self._update_skip_scope_button_text()
|
||||
# self.skip_scope_toggle_button.setStyleSheet("padding: 6px 10px;") # Old
|
||||
self.skip_scope_toggle_button.setStyleSheet("padding: 4px 10px;") # Standardized padding
|
||||
self.skip_scope_toggle_button.setMinimumWidth(100)
|
||||
skip_input_and_button_layout.addWidget(self.skip_scope_toggle_button, 0)
|
||||
@@ -2799,9 +2763,15 @@ class DownloaderApp(QWidget):
|
||||
self.cancel_btn.setToolTip("Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory).")
|
||||
self.cancel_btn.setStyleSheet("padding: 4px 12px;")
|
||||
self.cancel_btn.clicked.connect(self.cancel_download_button_action)
|
||||
|
||||
self.error_btn = QPushButton("Error")
|
||||
self.error_btn.setToolTip("View error details (functionality TBD).")
|
||||
self.error_btn.setStyleSheet("padding: 4px 8px;") # Smaller padding
|
||||
self.error_btn.setEnabled(True) # Initially enabled
|
||||
btn_layout.addWidget(self.download_btn)
|
||||
btn_layout.addWidget(self.pause_btn)
|
||||
btn_layout.addWidget(self.cancel_btn)
|
||||
btn_layout.addWidget(self.error_btn) # Add the error button to the layout
|
||||
self.standard_action_buttons_widget.setLayout(btn_layout)
|
||||
|
||||
self.bottom_action_buttons_stack = QStackedWidget()
|
||||
@@ -2871,13 +2841,10 @@ class DownloaderApp(QWidget):
|
||||
self.future_settings_button.setStyleSheet("padding: 4px 6px;") # Standardized padding
|
||||
self.future_settings_button.setToolTip("Open placeholder for future settings.")
|
||||
self.future_settings_button.clicked.connect(self._show_future_settings_dialog)
|
||||
|
||||
# Revert stretch factors to original values (likely 1 for these buttons to allow some stretch)
|
||||
char_manage_layout.addWidget(self.add_to_filter_button, 1)
|
||||
char_manage_layout.addWidget(self.delete_char_button, 1)
|
||||
char_manage_layout.addWidget(self.known_names_help_button, 0) # Keep stretch 0 for fixed width
|
||||
char_manage_layout.addWidget(self.future_settings_button, 0) # Add the new settings button
|
||||
# char_manage_layout.addStretch(1) # Remove the added stretch from previous change
|
||||
left_layout.addLayout(char_manage_layout)
|
||||
left_layout.addStretch(0) # Add back the stretch for the left_layout
|
||||
|
||||
@@ -3126,26 +3093,21 @@ class DownloaderApp(QWidget):
|
||||
"""
|
||||
|
||||
def browse_directory(self):
|
||||
# Determine a safe starting path
|
||||
initial_dir_text = self.dir_input.text()
|
||||
start_path = ""
|
||||
if initial_dir_text and os.path.isdir(initial_dir_text):
|
||||
start_path = initial_dir_text
|
||||
else:
|
||||
# Fallback to standard locations if input is invalid or empty
|
||||
home_location = QStandardPaths.writableLocation(QStandardPaths.HomeLocation)
|
||||
documents_location = QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation)
|
||||
if home_location and os.path.isdir(home_location):
|
||||
start_path = home_location
|
||||
elif documents_location and os.path.isdir(documents_location):
|
||||
start_path = documents_location
|
||||
# If all else fails, start_path remains "", letting Qt decide.
|
||||
|
||||
self.log_signal.emit(f"ℹ️ Opening folder dialog. Suggested start path: '{start_path}'")
|
||||
|
||||
try:
|
||||
# Use Qt's non-native dialog to potentially avoid OS-level hangs/warnings
|
||||
# The options are combined directly here.
|
||||
folder = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Download Folder",
|
||||
@@ -3159,7 +3121,6 @@ class DownloaderApp(QWidget):
|
||||
else:
|
||||
self.log_signal.emit(f"ℹ️ Folder selection cancelled by user.")
|
||||
except RuntimeError as e:
|
||||
# This can sometimes happen if Qt has issues with the windowing system or graphics
|
||||
self.log_signal.emit(f"❌ RuntimeError opening folder dialog: {e}. This might indicate a deeper Qt or system issue.")
|
||||
QMessageBox.critical(self, "Dialog Error", f"A runtime error occurred while trying to open the folder dialog: {e}")
|
||||
except Exception as e:
|
||||
@@ -4209,8 +4170,6 @@ class DownloaderApp(QWidget):
|
||||
if self._is_download_active():
|
||||
QMessageBox.warning(self, "Busy", "A download is already running.")
|
||||
return False # Indicate failure to start
|
||||
|
||||
# Check if this "Start Download" is for processing a queue from the creator popup
|
||||
if not direct_api_url and self.favorite_download_queue and not self.is_processing_favorites_queue:
|
||||
is_from_creator_popup = False
|
||||
if self.favorite_download_queue: # Ensure queue is not empty before peeking
|
||||
@@ -4219,15 +4178,9 @@ class DownloaderApp(QWidget):
|
||||
is_from_creator_popup = True
|
||||
|
||||
if is_from_creator_popup:
|
||||
# The queue was populated by the creator popup.
|
||||
# The link_input field contains display names and should be ignored for URL parsing.
|
||||
# Start processing the queue directly.
|
||||
self.log_signal.emit(f"ℹ️ Detected {len(self.favorite_download_queue)} creators queued from popup. Starting processing...")
|
||||
self._process_next_favorite_download() # This will set is_processing_favorites_queue
|
||||
return True # Indicate that the process has started
|
||||
# If not from creator_popup, let the normal flow handle it.
|
||||
# This allows other uses of favorite_download_queue (e.g., from Favorite Artists/Posts dialogs)
|
||||
# to proceed if they call start_download differently or if link_input is meant to be a URL.
|
||||
|
||||
|
||||
if self.favorite_mode_checkbox and self.favorite_mode_checkbox.isChecked() and not direct_api_url:
|
||||
@@ -4250,7 +4203,6 @@ class DownloaderApp(QWidget):
|
||||
if num_threads_from_gui < 1: num_threads_from_gui = 1
|
||||
except ValueError:
|
||||
QMessageBox.critical(self, "Thread Count Error", "Invalid number of threads. Please enter a positive number.")
|
||||
# self.set_ui_enabled(True) # Removed
|
||||
return False # Indicate failure to start
|
||||
|
||||
if use_multithreading_enabled_by_checkbox:
|
||||
@@ -4307,9 +4259,6 @@ class DownloaderApp(QWidget):
|
||||
selected_cookie_file_path_for_backend = self.selected_cookie_filepath if use_cookie_from_checkbox and self.selected_cookie_filepath else None
|
||||
|
||||
if use_cookie_from_checkbox and not direct_api_url: # Don't show for individual items in favorite queue if they fail this check
|
||||
# Perform an early check for cookies if 'Use Cookie' is checked for the main UI interaction
|
||||
# The actual cookies for download/API calls will be prepared by the backend.
|
||||
# This is for proactive UI feedback.
|
||||
temp_cookies_for_check = prepare_cookies_for_request(
|
||||
use_cookie_for_this_run, # Use the potentially modified flag
|
||||
cookie_text_from_input,
|
||||
@@ -4364,7 +4313,6 @@ class DownloaderApp(QWidget):
|
||||
QMessageBox.critical(self, "Configuration Error",
|
||||
"The main 'Download Location' must be set in the UI "
|
||||
"before downloading favorites with 'Artist Folders' scope.")
|
||||
# self.set_ui_enabled(True) # Removed
|
||||
if self.is_processing_favorites_queue: # Ensure queue logic can proceed
|
||||
self.log_signal.emit(f"❌ Favorite download for '{api_url}' skipped: Main download directory not set.")
|
||||
return False # Indicate failure to start
|
||||
@@ -4373,7 +4321,6 @@ class DownloaderApp(QWidget):
|
||||
QMessageBox.critical(self, "Directory Error",
|
||||
f"The main 'Download Location' ('{main_ui_download_dir}') "
|
||||
"does not exist or is not a directory. Please set a valid one for 'Artist Folders' scope.")
|
||||
# self.set_ui_enabled(True) # Removed
|
||||
if self.is_processing_favorites_queue:
|
||||
self.log_signal.emit(f"❌ Favorite download for '{api_url}' skipped: Main download directory invalid.")
|
||||
return False # Indicate failure to start
|
||||
@@ -4381,7 +4328,6 @@ class DownloaderApp(QWidget):
|
||||
else:
|
||||
if not extract_links_only and not main_ui_download_dir:
|
||||
QMessageBox.critical(self, "Input Error", "Download Directory is required when not in 'Only Links' mode.")
|
||||
# self.set_ui_enabled(True) # Removed
|
||||
return False # Indicate failure to start
|
||||
|
||||
if not extract_links_only and main_ui_download_dir and not os.path.isdir(main_ui_download_dir):
|
||||
@@ -4394,11 +4340,9 @@ class DownloaderApp(QWidget):
|
||||
self.log_signal.emit(f"ℹ️ Created directory: {main_ui_download_dir}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}")
|
||||
# self.set_ui_enabled(True) # Removed
|
||||
return False # Indicate failure to start
|
||||
else:
|
||||
self.log_signal.emit("❌ Download cancelled: Output directory does not exist and was not created.")
|
||||
# self.set_ui_enabled(True) # Removed
|
||||
return False # Indicate failure to start
|
||||
effective_output_dir_for_run = main_ui_download_dir
|
||||
|
||||
@@ -4462,11 +4406,9 @@ class DownloaderApp(QWidget):
|
||||
|
||||
if msg_box.clickedButton() == cancel_button:
|
||||
self.log_signal.emit("❌ Download cancelled by user due to Manga Mode & Page Range warning.")
|
||||
# self.set_ui_enabled(True); # Removed
|
||||
return False # Indicate failure to start
|
||||
except ValueError as e:
|
||||
QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}")
|
||||
# self.set_ui_enabled(True); # Removed
|
||||
return False # Indicate failure to start
|
||||
self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None
|
||||
|
||||
@@ -4520,7 +4462,6 @@ class DownloaderApp(QWidget):
|
||||
|
||||
if dialog_result == CONFIRM_ADD_ALL_CANCEL_DOWNLOAD:
|
||||
self.log_signal.emit("❌ Download cancelled by user at new name confirmation stage.")
|
||||
# self.set_ui_enabled(True); # Removed
|
||||
return False # Indicate failure to start
|
||||
elif isinstance(dialog_result, list): # User chose to add selected items
|
||||
if dialog_result: # If the list of selected filter_objects is not empty
|
||||
@@ -4592,6 +4533,8 @@ class DownloaderApp(QWidget):
|
||||
self.progress_label.setText("Progress: Initializing...")
|
||||
|
||||
self.retryable_failed_files_info.clear() # Clear previous retryable failures before new session
|
||||
self.permanently_failed_files_for_dialog.clear() # Clear permanent failures for new session
|
||||
|
||||
manga_date_file_counter_ref_for_thread = None
|
||||
if manga_mode and self.manga_filename_style == STYLE_DATE_BASED and not extract_links_only:
|
||||
manga_date_file_counter_ref_for_thread = None # Placeholder, actual init in thread
|
||||
@@ -4799,6 +4742,18 @@ class DownloaderApp(QWidget):
|
||||
if self.pause_event: self.pause_event.clear()
|
||||
self.is_paused = False # Ensure pause state is reset on error
|
||||
|
||||
def _show_error_files_dialog(self):
|
||||
"""Shows the dialog with files that were skipped due to errors."""
|
||||
if not self.permanently_failed_files_for_dialog:
|
||||
QMessageBox.information(self, "No Errors Logged",
|
||||
"No files were recorded as skipped due to errors in the last session or after retries.")
|
||||
return
|
||||
dialog = ErrorFilesDialog(self.permanently_failed_files_for_dialog, self) # type: ignore
|
||||
dialog.retry_selected_signal.connect(self._handle_retry_from_error_dialog)
|
||||
dialog.exec_()
|
||||
def _handle_retry_from_error_dialog(self, selected_files_to_retry):
|
||||
self._start_failed_files_retry_session(files_to_retry_list=selected_files_to_retry)
|
||||
|
||||
def _handle_retryable_file_failure(self, list_of_retry_details):
|
||||
"""Appends details of files that failed but might be retryable later."""
|
||||
if list_of_retry_details:
|
||||
@@ -5059,10 +5014,11 @@ class DownloaderApp(QWidget):
|
||||
elif future.exception():
|
||||
self.log_signal.emit(f"❌ Post processing worker error: {future.exception()}")
|
||||
else: # Future completed successfully
|
||||
downloaded_files_from_future, skipped_files_from_future, kept_originals_from_future, retryable_failures_from_post = future.result()
|
||||
downloaded_files_from_future, skipped_files_from_future, kept_originals_from_future, retryable_failures_from_post, permanent_failures_from_post = future.result()
|
||||
if retryable_failures_from_post:
|
||||
self.retryable_failed_files_info.extend(retryable_failures_from_post)
|
||||
|
||||
if permanent_failures_from_post:
|
||||
self.permanently_failed_files_for_dialog.extend(permanent_failures_from_post)
|
||||
with self.downloaded_files_lock:
|
||||
self.download_counter += downloaded_files_from_future
|
||||
self.skip_counter += skipped_files_from_future
|
||||
@@ -5263,6 +5219,7 @@ class DownloaderApp(QWidget):
|
||||
|
||||
if hasattr(self, 'link_input'): # Update for queue sync
|
||||
self.last_link_input_text_for_queue_sync = self.link_input.text()
|
||||
self.permanently_failed_files_for_dialog.clear() # Clear errors on soft reset
|
||||
self.filter_character_list(self.character_search_input.text())
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION # Reset scope
|
||||
self._update_favorite_scope_button_text()
|
||||
@@ -5305,7 +5262,8 @@ class DownloaderApp(QWidget):
|
||||
if self.retryable_failed_files_info:
|
||||
self.log_signal.emit(f" Discarding {len(self.retryable_failed_files_info)} pending retryable file(s) due to cancellation.")
|
||||
self.retryable_failed_files_info.clear()
|
||||
self.favorite_download_queue.clear()
|
||||
self.favorite_download_queue.clear() # type: ignore
|
||||
self.permanently_failed_files_for_dialog.clear() # Also clear permanent failures on cancel
|
||||
self.is_processing_favorites_queue = False
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION # Reset scope
|
||||
self._update_favorite_scope_button_text()
|
||||
@@ -5386,6 +5344,9 @@ class DownloaderApp(QWidget):
|
||||
return # Don't fully reset UI if retrying
|
||||
else:
|
||||
self.log_signal.emit("ℹ️ User chose not to retry failed files.")
|
||||
self.permanently_failed_files_for_dialog.extend(self.retryable_failed_files_info) # Add to permanent list
|
||||
if self.permanently_failed_files_for_dialog: # Log if there are now permanent errors
|
||||
self.log_signal.emit(f"🆘 Error button enabled. {len(self.permanently_failed_files_for_dialog)} file(s) can be viewed.")
|
||||
self.retryable_failed_files_info.clear() # Clear if not retrying
|
||||
|
||||
self.is_fetcher_thread_running = False # Ensure it's reset
|
||||
@@ -5417,19 +5378,24 @@ class DownloaderApp(QWidget):
|
||||
self.scan_content_images_checkbox.setChecked(False)
|
||||
self.scan_content_images_checkbox.setToolTip(self._original_scan_content_tooltip)
|
||||
|
||||
def _start_failed_files_retry_session(self):
|
||||
self.log_signal.emit(f"🔄 Starting retry session for {len(self.retryable_failed_files_info)} file(s)...")
|
||||
def _start_failed_files_retry_session(self, files_to_retry_list=None):
|
||||
if files_to_retry_list:
|
||||
self.files_for_current_retry_session = list(files_to_retry_list)
|
||||
self.permanently_failed_files_for_dialog = [f for f in self.permanently_failed_files_for_dialog if f not in files_to_retry_list]
|
||||
else:
|
||||
self.files_for_current_retry_session = list(self.retryable_failed_files_info)
|
||||
self.retryable_failed_files_info.clear() # Clear original list if using default
|
||||
self.log_signal.emit(f"🔄 Starting retry session for {len(self.files_for_current_retry_session)} file(s)...")
|
||||
self.set_ui_enabled(False) # Disable UI, but cancel button will be enabled
|
||||
if self.cancel_btn: self.cancel_btn.setText("❌ Cancel Retry")
|
||||
|
||||
self.files_for_current_retry_session = list(self.retryable_failed_files_info)
|
||||
self.retryable_failed_files_info.clear() # Clear original list
|
||||
|
||||
self.active_retry_futures = []
|
||||
self.processed_retry_count = 0
|
||||
self.succeeded_retry_count = 0
|
||||
self.failed_retry_count_in_session = 0 # Renamed to avoid clash
|
||||
self.total_files_for_retry = len(self.files_for_current_retry_session)
|
||||
self.active_retry_futures_map = {} # Initialize map for tracking futures to job_details
|
||||
|
||||
self.progress_label.setText(f"Retrying 0 / {self.total_files_for_retry} files...")
|
||||
self.cancellation_event.clear() # Clear main cancellation for retry session
|
||||
@@ -5476,6 +5442,7 @@ class DownloaderApp(QWidget):
|
||||
for job_details in self.files_for_current_retry_session:
|
||||
future = self.retry_thread_pool.submit(self._execute_single_file_retry, job_details, common_ppw_args_for_retry)
|
||||
future.add_done_callback(self._handle_retry_future_result)
|
||||
self.active_retry_futures_map[future] = job_details # Map future to its job_details
|
||||
self.active_retry_futures.append(future)
|
||||
|
||||
def _execute_single_file_retry(self, job_details, common_args):
|
||||
@@ -5521,19 +5488,23 @@ class DownloaderApp(QWidget):
|
||||
self.log_signal.emit(f"❌ Retry task worker error: {future.exception()}")
|
||||
else:
|
||||
was_successful = future.result()
|
||||
job_details = self.active_retry_futures_map.pop(future, None) # Get and remove from map
|
||||
if was_successful:
|
||||
self.succeeded_retry_count += 1
|
||||
else:
|
||||
self.failed_retry_count_in_session += 1
|
||||
if job_details: # If retry failed, add its details to the permanent list
|
||||
self.permanently_failed_files_for_dialog.append(job_details)
|
||||
except Exception as e:
|
||||
self.log_signal.emit(f"❌ Error in _handle_retry_future_result: {e}")
|
||||
self.failed_retry_count_in_session +=1
|
||||
self.failed_retry_count_in_session += 1
|
||||
|
||||
self.progress_label.setText(f"Retrying {self.processed_retry_count} / {self.total_files_for_retry} files... (Succeeded: {self.succeeded_retry_count}, Failed: {self.failed_retry_count_in_session})")
|
||||
|
||||
if self.processed_retry_count >= self.total_files_for_retry:
|
||||
if all(f.done() for f in self.active_retry_futures):
|
||||
self._retry_session_finished()
|
||||
QTimer.singleShot(0, self._retry_session_finished)
|
||||
|
||||
|
||||
def _retry_session_finished(self):
|
||||
self.log_signal.emit("🏁 Retry session finished.")
|
||||
@@ -5544,8 +5515,12 @@ class DownloaderApp(QWidget):
|
||||
self.retry_thread_pool = None
|
||||
|
||||
self.active_retry_futures.clear()
|
||||
self.active_retry_futures_map.clear() # Clear the map
|
||||
self.files_for_current_retry_session.clear()
|
||||
|
||||
|
||||
if self.permanently_failed_files_for_dialog: # Log if there are permanent errors after retry
|
||||
self.log_signal.emit(f"🆘 Error button enabled. {len(self.permanently_failed_files_for_dialog)} file(s) ultimately failed and can be viewed.")
|
||||
|
||||
self.set_ui_enabled(True) # Re-enable UI
|
||||
if self.cancel_btn: self.cancel_btn.setText("❌ Cancel & Reset UI") # Reset cancel button text
|
||||
self.progress_label.setText(f"Retry Finished. Succeeded: {self.succeeded_retry_count}, Failed: {self.failed_retry_count_in_session}. Ready for new task.")
|
||||
@@ -5592,6 +5567,7 @@ class DownloaderApp(QWidget):
|
||||
self.already_logged_bold_key_terms.clear()
|
||||
self.missed_key_terms_buffer.clear()
|
||||
self.favorite_download_queue.clear()
|
||||
self.permanently_failed_files_for_dialog.clear() # Clear errors on full reset
|
||||
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION # Reset scope
|
||||
self._update_favorite_scope_button_text()
|
||||
self.retryable_failed_files_info.clear() # Clear any pending retries
|
||||
@@ -5645,6 +5621,7 @@ class DownloaderApp(QWidget):
|
||||
self.missed_key_terms_buffer.clear()
|
||||
if self.missed_character_log_output: self.missed_character_log_output.clear()
|
||||
|
||||
self.permanently_failed_files_for_dialog.clear() # Ensure cleared on default reset too
|
||||
self.allow_multipart_download_setting = False # Default to OFF
|
||||
self._update_multipart_toggle_button_text() # Update button text
|
||||
|
||||
@@ -6188,8 +6165,6 @@ class DownloaderApp(QWidget):
|
||||
"""Creates and shows the empty popup dialog."""
|
||||
dialog = EmptyPopupDialog(self.app_base_dir, self)
|
||||
if dialog.exec_() == QDialog.Accepted: # "Add Selected" was clicked in the dialog
|
||||
# The dialog's _handle_add_selected method has already set the link_input text.
|
||||
# Now, we populate the internal download queue if creators were selected.
|
||||
if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
|
||||
self.favorite_download_queue.clear() # Clear any previous queue items
|
||||
|
||||
@@ -6197,9 +6172,6 @@ class DownloaderApp(QWidget):
|
||||
service = creator_data.get('service')
|
||||
creator_id = creator_data.get('id')
|
||||
creator_name = creator_data.get('name', 'Unknown Creator')
|
||||
|
||||
# Reconstruct URL for the queue item
|
||||
# (Alternatively, creator_data could store the full URL directly)
|
||||
domain = dialog._get_domain_for_service(service)
|
||||
|
||||
if service and creator_id:
|
||||
@@ -6218,9 +6190,6 @@ class DownloaderApp(QWidget):
|
||||
if hasattr(self, 'link_input'): # Update last_link_input for sync after queue is rebuilt
|
||||
self.last_link_input_text_for_queue_sync = self.link_input.text()
|
||||
|
||||
# If the queue is empty here, it means "Add Selected" was clicked with no items checked in the dialog.
|
||||
# The link_input would have been cleared or set to empty by the dialog's logic.
|
||||
|
||||
def _show_favorite_artists_dialog(self):
|
||||
if self._is_download_active() or self.is_processing_favorites_queue:
|
||||
QMessageBox.warning(self, "Busy", "Another download operation is already in progress.")
|
||||
@@ -6239,9 +6208,6 @@ class DownloaderApp(QWidget):
|
||||
if selected_artists:
|
||||
if len(selected_artists) > 1:
|
||||
display_names = ", ".join([artist['name'] for artist in selected_artists])
|
||||
# For multiple artists, we don't set the link_input as it's confusing.
|
||||
# The queue will handle individual URLs.
|
||||
# self.link_input.setText(display_names) # Avoid setting this
|
||||
if self.link_input: # Clear it if it was showing a single URL before
|
||||
self.link_input.clear()
|
||||
self.link_input.setPlaceholderText(f"{len(selected_artists)} favorite artists selected for download queue.")
|
||||
@@ -6255,7 +6221,6 @@ class DownloaderApp(QWidget):
|
||||
self.favorite_download_queue.append({'url': artist_data['url'], 'name': artist_data['name'], 'name_for_folder': artist_data['name'], 'type': 'artist'})
|
||||
|
||||
if not self.is_processing_favorites_queue:
|
||||
# self.is_processing_favorites_queue = True # This will be set in _process_next_favorite_download
|
||||
self._process_next_favorite_download()
|
||||
else:
|
||||
self.log_signal.emit("ℹ️ No favorite artists were selected for download.")
|
||||
@@ -6274,8 +6239,6 @@ class DownloaderApp(QWidget):
|
||||
'app_base_dir': self.app_base_dir
|
||||
}
|
||||
global KNOWN_NAMES # Ensure we have access to the global
|
||||
|
||||
# Perform cookie check before showing the FavoritePostsDialog if cookies are enabled
|
||||
if cookies_config['use_cookie']:
|
||||
temp_cookies_for_check = prepare_cookies_for_request(
|
||||
cookies_config['use_cookie'],
|
||||
@@ -6295,8 +6258,6 @@ class DownloaderApp(QWidget):
|
||||
if selected_posts:
|
||||
self.log_signal.emit(f"ℹ️ Queuing {len(selected_posts)} favorite post(s) for download.")
|
||||
for post_data in selected_posts:
|
||||
# Construct direct post URL: https://<domain>/<service>/user/<creator_id>/post/<post_id>
|
||||
# For now, assume kemono.su. TODO: Handle coomer.su if applicable
|
||||
domain = "kemono.su" # Or determine from service/parent app settings
|
||||
direct_post_url = f"https://{domain}/{post_data['service']}/user/{post_data['creator_id']}/post/{post_data['post_id']}"
|
||||
|
||||
@@ -6309,7 +6270,6 @@ class DownloaderApp(QWidget):
|
||||
self.favorite_download_queue.append(queue_item)
|
||||
|
||||
if not self.is_processing_favorites_queue:
|
||||
# self.is_processing_favorites_queue = True # This will be set in _process_next_favorite_download
|
||||
self._process_next_favorite_download()
|
||||
else:
|
||||
self.log_signal.emit("ℹ️ No favorite posts were selected for download.")
|
||||
@@ -6317,26 +6277,18 @@ class DownloaderApp(QWidget):
|
||||
self.log_signal.emit("ℹ️ Favorite posts selection cancelled.")
|
||||
|
||||
def _process_next_favorite_download(self):
|
||||
# If a download is already active (could be a regular download or a previous favorite item),
|
||||
# wait for it to complete. download_finished will re-trigger this method.
|
||||
if self._is_download_active():
|
||||
self.log_signal.emit("ℹ️ Waiting for current download to finish before starting next favorite.")
|
||||
return
|
||||
|
||||
# If the queue is empty, it means all favorites (if any were queued) are done.
|
||||
if not self.favorite_download_queue:
|
||||
if self.is_processing_favorites_queue: # If we were in the middle of processing favorites
|
||||
self.is_processing_favorites_queue = False
|
||||
item_type_log = "item" # Default
|
||||
# Check if current_processing_favorite_item_info was set (i.e., at least one item was processed)
|
||||
if hasattr(self, 'current_processing_favorite_item_info') and self.current_processing_favorite_item_info:
|
||||
item_type_log = self.current_processing_favorite_item_info.get('type', 'item')
|
||||
self.log_signal.emit(f"✅ All {item_type_log} downloads from favorite queue have been processed.")
|
||||
self.set_ui_enabled(True) # Re-enable UI fully
|
||||
return
|
||||
|
||||
# If we reach here, queue is not empty and no other download is active.
|
||||
# This is where we commit to processing the next favorite item.
|
||||
if not self.is_processing_favorites_queue: # Set flag if starting a new queue processing
|
||||
self.is_processing_favorites_queue = True
|
||||
self.current_processing_favorite_item_info = self.favorite_download_queue.popleft()
|
||||
@@ -6346,7 +6298,6 @@ class DownloaderApp(QWidget):
|
||||
self.log_signal.emit(f"▶️ Processing next favorite from queue: '{item_display_name}' ({next_url})")
|
||||
|
||||
override_dir = None
|
||||
# Determine scope: from popup if available, otherwise from main app's favorite scope setting
|
||||
item_scope = self.current_processing_favorite_item_info.get('scope_from_popup')
|
||||
if item_scope is None: # Not from creator popup, use the main favorite scope
|
||||
item_scope = self.favorite_download_scope
|
||||
@@ -6362,12 +6313,7 @@ class DownloaderApp(QWidget):
|
||||
success_starting_download = self.start_download(direct_api_url=next_url, override_output_dir=override_dir)
|
||||
|
||||
if not success_starting_download:
|
||||
# If start_download failed (e.g., due to a QMessageBox validation error),
|
||||
# we need to manually trigger the logic that download_finished would handle
|
||||
# to ensure the queue continues or terminates correctly.
|
||||
self.log_signal.emit(f"⚠️ Failed to initiate download for '{item_display_name}'. Skipping this item in queue.")
|
||||
# Simulate a "cancelled" finish for this item to process the next or end the queue.
|
||||
# This will call _process_next_favorite_download again if queue is not empty via download_finished.
|
||||
self.download_finished(total_downloaded=0, total_skipped=1, cancelled_by_user=True, kept_original_names_list=[])
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user