diff --git a/downloader_utils.py b/downloader_utils.py index ac5a5eb..d905902 100644 --- a/downloader_utils.py +++ b/downloader_utils.py @@ -305,19 +305,43 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo if not use_cookie_flag: return None - if cookie_text_input: - logger_func(" πŸͺ Using cookies from UI text input.") - return parse_cookie_string(cookie_text_input) - elif selected_cookie_file_path: + # Attempt 1: Selected cookie file + if selected_cookie_file_path: logger_func(f" πŸͺ Attempting to load cookies from selected file: '{os.path.basename(selected_cookie_file_path)}'...") - return load_cookies_from_netscape_file(selected_cookie_file_path, logger_func) - elif app_base_dir: - cookies_filepath = os.path.join(app_base_dir, "cookies.txt") - logger_func(f" πŸͺ No UI text or specific file selected. Attempting to load default '{os.path.basename(cookies_filepath)}' from app directory...") - return load_cookies_from_netscape_file(cookies_filepath, logger_func) - else: - logger_func(" πŸͺ Cookie usage enabled, but no text input, specific file, or app base directory provided for cookies.txt.") - return None + cookies = load_cookies_from_netscape_file(selected_cookie_file_path, logger_func) + if cookies: + return cookies + else: + logger_func(f" ⚠️ Failed to load cookies from selected file: '{os.path.basename(selected_cookie_file_path)}'. Trying other methods.") + # Fall through if selected file is invalid or not found + + # Attempt 2: Default cookies.txt in app directory + # This is tried if no specific file was selected OR if the selected file was provided but failed to load. + if app_base_dir: # Only proceed if app_base_dir is available + # Avoid re-logging "not found" or "failed" if a selected_cookie_file_path was already attempted and failed. + # Only log the attempt for default if no selected_cookie_file_path was given. + default_cookies_path = os.path.join(app_base_dir, "cookies.txt") + if os.path.exists(default_cookies_path): # Only attempt if it exists + if not selected_cookie_file_path: # Log attempt only if we didn't just try a selected file + logger_func(f" πŸͺ No specific file selected. Attempting to load default '{os.path.basename(default_cookies_path)}' from app directory...") + cookies = load_cookies_from_netscape_file(default_cookies_path, logger_func) + if cookies: + return cookies + elif not selected_cookie_file_path: # Log failure only if we tried default as primary file method + logger_func(f" ⚠️ Failed to load cookies from default file: '{os.path.basename(default_cookies_path)}'. Trying text input.") + # Fall through if default file is invalid or not found + + # Attempt 3: Cookies from UI text input + if cookie_text_input: + logger_func(" πŸͺ Using cookies from UI text input (as file methods failed or were not applicable).") + cookies = parse_cookie_string(cookie_text_input) + if cookies: + return cookies + else: + logger_func(" ⚠️ UI cookie text input was provided but was empty or invalid.") + + logger_func(" πŸͺ Cookie usage enabled, but no valid cookies found from any source (selected file, default file, or text input).") + return None def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None): if cancellation_event and cancellation_event.is_set(): # type: ignore @@ -645,6 +669,7 @@ class PostProcessorWorker: allow_multipart_download=True, cookie_text="", # Added missing parameter use_cookie=False, # Added missing parameter + override_output_dir=None, # New parameter selected_cookie_file=None, # Added missing parameter app_base_dir=None, # New parameter for app's base directory manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, # New parameter for date-based prefix @@ -652,7 +677,7 @@ class PostProcessorWorker: scan_content_for_images=False, # New flag for scanning HTML content manga_global_file_counter_ref=None, # New parameter for global numbering ): # type: ignore - self.post = post_data + self.post = post_data # type: ignore self.download_root = download_root self.known_names = known_names self.filter_character_list_objects_initial = filter_character_list if filter_character_list else [] # Store initial @@ -700,9 +725,11 @@ class PostProcessorWorker: self.manga_date_prefix = manga_date_prefix # Store the prefix self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store global counter self.use_cookie = use_cookie # Store cookie setting + self.override_output_dir = override_output_dir # Store the override directory self.scan_content_for_images = scan_content_for_images # Store new flag if self.compress_images and Image is None: + # type: ignore self.logger("⚠️ Image compression disabled: Pillow library not found.") self.compress_images = False @@ -723,9 +750,9 @@ class PostProcessorWorker: return self.cancellation_event.is_set() def _check_pause(self, context_message="Operation"): - if self.pause_event and self.pause_event.is_set(): + if self.pause_event and self.pause_event.is_set(): # type: ignore self.logger(f" {context_message} paused...") - while self.pause_event.is_set(): # Loop while pause_event is set + while self.pause_event.is_set(): # type: ignore # Loop while pause_event is set if self.check_cancel(): self.logger(f" {context_message} cancelled while paused.") return True # Indicates cancellation occurred @@ -1341,7 +1368,7 @@ class PostProcessorWorker: self.logger(f" -> Skip Post (Folder Keyword): Potential folder '{folder_name_to_check}' contains '{matched_skip}'.") return 0, num_potential_files_in_post, [], [] - if (self.show_external_links or self.extract_links_only) and post_content_html: + 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: unique_links_data = {} @@ -1597,7 +1624,7 @@ class PostProcessorWorker: total_skipped_this_post += 1 continue - current_path_for_file = self.download_root + current_path_for_file = self.override_output_dir if self.override_output_dir else self.download_root # Use override if provided if self.use_subfolders: char_title_subfolder_name = None @@ -1704,6 +1731,7 @@ class DownloadThread(QThread): manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, # New parameter allow_multipart_download=True, selected_cookie_file=None, # New parameter for selected cookie file + override_output_dir=None, # New parameter app_base_dir=None, # New parameter manga_date_file_counter_ref=None, # New parameter manga_global_file_counter_ref=None, # New parameter for global numbering @@ -1714,7 +1742,7 @@ class DownloadThread(QThread): super().__init__() self.api_url_input = api_url_input self.output_dir = output_dir - self.known_names = list(known_names_copy) + self.known_names = list(known_names_copy) # type: ignore self.cancellation_event = cancellation_event self.pause_event = pause_event # Store pause_event self.skip_current_file_flag = skip_current_file_flag @@ -1758,6 +1786,7 @@ class DownloadThread(QThread): self.app_base_dir = app_base_dir # Store app base dir self.cookie_text = cookie_text # Store cookie text self.use_cookie = use_cookie # Store cookie setting + self.override_output_dir = override_output_dir # Store override dir self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread self.scan_content_for_images = scan_content_for_images # Store new flag self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store for global numbering @@ -1890,6 +1919,7 @@ class DownloadThread(QThread): selected_cookie_file=self.selected_cookie_file, # Pass selected cookie file app_base_dir=self.app_base_dir, # Pass app_base_dir cookie_text=self.cookie_text, # Pass cookie text + override_output_dir=self.override_output_dir, # Pass override dir manga_global_file_counter_ref=self.manga_global_file_counter_ref, # Pass the ref use_cookie=self.use_cookie, # Pass cookie setting to worker manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref diff --git a/main.py b/main.py index 010e6ec..3acea50 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,8 @@ import queue import hashlib import http.client import traceback -import subprocess # Added for opening files cross-platform +import html +import subprocess import random from collections import deque @@ -22,8 +23,8 @@ from PyQt5.QtGui import ( from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton, QButtonGroup, QCheckBox, QSplitter, - QDialog, QStackedWidget, QScrollArea, QListWidgetItem, - QAbstractItemView, # Added for QListWidget.NoSelection + QDialog, QStackedWidget, QScrollArea, QListWidgetItem, QSizePolicy, + QAbstractItemView, QFrame, QAbstractButton ) @@ -35,7 +36,7 @@ try: except ImportError: Image = None -from io import BytesIO # Keep this if used elsewhere, though not directly in this diff +from io import BytesIO try: print("Attempting to import from downloader_utils...") @@ -45,19 +46,20 @@ try: extract_post_info, download_from_api, PostProcessorSignals, + prepare_cookies_for_request, PostProcessorWorker, DownloadThread as BackendDownloadThread, SKIP_SCOPE_FILES, SKIP_SCOPE_POSTS, SKIP_SCOPE_BOTH, - CHAR_SCOPE_TITLE, # Added for completeness if used directly - CHAR_SCOPE_FILES, # Ensure this is imported - CHAR_SCOPE_BOTH, + CHAR_SCOPE_TITLE, + CHAR_SCOPE_FILES, + CHAR_SCOPE_BOTH, CHAR_SCOPE_COMMENTS, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, - STYLE_DATE_BASED, # Import new manga style - STYLE_POST_TITLE_GLOBAL_NUMBERING # Import new manga style - # IMAGE_EXTENSIONS will be used from downloader_utils directly + STYLE_DATE_BASED, + STYLE_POST_TITLE_GLOBAL_NUMBERING + ) print("Successfully imported names from downloader_utils.") except ImportError as e: @@ -66,18 +68,16 @@ except ImportError as e: print(f"--- Check downloader_utils.py for syntax errors or missing dependencies. ---") KNOWN_NAMES = [] PostProcessorWorker = object - # Create a mock PostProcessorSignals class with the expected signals class _MockPostProcessorSignals(QObject): progress_signal = pyqtSignal(str) file_download_status_signal = pyqtSignal(bool) external_link_signal = pyqtSignal(str, str, str, str) file_progress_signal = pyqtSignal(str, object) missed_character_post_signal = pyqtSignal(str, str) - # Add any other signals that might be expected if the real class is extended def __init__(self, parent=None): super().__init__(parent) print("WARNING: Using MOCK PostProcessorSignals due to import error from downloader_utils.py. Some functionalities might be impaired.") - PostProcessorSignals = _MockPostProcessorSignals # Use the mock class + PostProcessorSignals = _MockPostProcessorSignals BackendDownloadThread = QThread def clean_folder_name(n): return str(n) def extract_post_info(u): return None, None, None @@ -91,7 +91,7 @@ except ImportError as e: CHAR_SCOPE_COMMENTS = "comments" FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later" STYLE_DATE_BASED = "date_based" - STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # Mock for safety + STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" except Exception as e: print(f"--- UNEXPECTED IMPORT ERROR ---") @@ -106,28 +106,30 @@ RECOMMENDED_MAX_THREADS = 50 MAX_FILE_THREADS_PER_POST_OR_WORKER = 10 POST_WORKER_BATCH_THRESHOLD = 30 POST_WORKER_NUM_BATCHES = 4 -SOFT_WARNING_THREAD_THRESHOLD = 40 # New constant for soft warning -POST_WORKER_BATCH_DELAY_SECONDS = 2.5 # Seconds -MAX_POST_WORKERS_WHEN_COMMENT_FILTERING = 3 # New constant +SOFT_WARNING_THREAD_THRESHOLD = 40 +POST_WORKER_BATCH_DELAY_SECONDS = 2.5 +MAX_POST_WORKERS_WHEN_COMMENT_FILTERING = 3 HTML_PREFIX = "" CONFIG_ORGANIZATION_NAME = "KemonoDownloader" CONFIG_APP_NAME_MAIN = "ApplicationSettings" MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1" -STYLE_POST_TITLE = "post_title" # Already defined, but ensure it's STYLE_POST_TITLE +STYLE_POST_TITLE = "post_title" STYLE_ORIGINAL_NAME = "original_name" -STYLE_DATE_BASED = "date_based" # New style for date-based naming -STYLE_POST_TITLE_GLOBAL_NUMBERING = STYLE_POST_TITLE_GLOBAL_NUMBERING # Use imported or mocked +STYLE_DATE_BASED = "date_based" +STYLE_POST_TITLE_GLOBAL_NUMBERING = STYLE_POST_TITLE_GLOBAL_NUMBERING SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1" ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1" -USE_COOKIE_KEY = "useCookieV1" # New setting key -COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text +USE_COOKIE_KEY = "useCookieV1" +COOKIE_TEXT_KEY = "cookieTextV1" CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1" -SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1" # New setting key +SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1" CONFIRM_ADD_ALL_ACCEPTED = 1 +FAVORITE_SCOPE_SELECTED_LOCATION = "selected_location" +FAVORITE_SCOPE_ARTIST_FOLDERS = "artist_folders" CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3 @@ -138,7 +140,7 @@ class ConfirmAddAllDialog(QDialog): self.setWindowTitle("Confirm Adding New Names") self.setModal(True) self.new_filter_objects_list = new_filter_objects_list - self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD # Default to cancel if closed + self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD main_layout = QVBoxLayout(self) @@ -153,14 +155,10 @@ class ConfirmAddAllDialog(QDialog): self.names_list_widget = QListWidget() for filter_obj in self.new_filter_objects_list: item_text = filter_obj["name"] - # Optionally, make group display more informative - # if filter_obj["is_group"]: - # item_text += f" (Group with aliases: {', '.join(filter_obj['aliases'])})" - list_item = QListWidgetItem(item_text) list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable) - list_item.setCheckState(Qt.Checked) # Default to checked - list_item.setData(Qt.UserRole, filter_obj) # Store the full filter object + list_item.setCheckState(Qt.Checked) + list_item.setData(Qt.UserRole, filter_obj) self.names_list_widget.addItem(list_item) main_layout.addWidget(self.names_list_widget) @@ -214,26 +212,23 @@ class ConfirmAddAllDialog(QDialog): item = self.names_list_widget.item(i) if item.checkState() == Qt.Checked: filter_obj = item.data(Qt.UserRole) - if filter_obj: # Should always be true if populated correctly + if filter_obj: selected_objects.append(filter_obj) - # self.user_choice will be the list of selected filter_obj, or empty list if none selected - self.user_choice = selected_objects + self.user_choice = selected_objects self.accept() def _reject_skip_adding(self): self.user_choice = CONFIRM_ADD_ALL_SKIP_ADDING - self.reject() # QDialog.reject() is fine, we check user_choice + self.reject() def _reject_cancel_download(self): self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD - self.reject() # QDialog.reject() is fine, we check user_choice + self.reject() def exec_(self): super().exec_() - # If user accepted but selected nothing, treat it as skipping addition if isinstance(self.user_choice, list) and not self.user_choice: - # QMessageBox.information(self, "No Selection", "No names were selected to be added. Skipping addition.") return CONFIRM_ADD_ALL_SKIP_ADDING return self.user_choice @@ -243,8 +238,6 @@ class KnownNamesFilterDialog(QDialog): super().__init__(parent) self.setWindowTitle("Add Known Names to Filter") self.setModal(True) - # Store the full list of known name objects. Each object is a dict. - # Sort them by the 'name' field for consistent display. self.all_known_name_entries = sorted(known_names_list, key=lambda x: x['name'].lower()) self.selected_entries_to_return = [] @@ -256,20 +249,19 @@ class KnownNamesFilterDialog(QDialog): main_layout.addWidget(self.search_input) self.names_list_widget = QListWidget() - self._populate_list_widget() # Populate with all entries initially + self._populate_list_widget() main_layout.addWidget(self.names_list_widget) - # Buttons layout: Select All, Deselect All, Add, Cancel 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) # Add to main buttons_layout + buttons_layout.addWidget(self.select_all_button) self.deselect_all_button = QPushButton("Deselect All") self.deselect_all_button.clicked.connect(self._deselect_all_items) - buttons_layout.addWidget(self.deselect_all_button) # Add to main buttons_layout - buttons_layout.addStretch(1) # Stretch between Deselect All and Add Selected + buttons_layout.addWidget(self.deselect_all_button) + buttons_layout.addStretch(1) self.add_button = QPushButton("Add Selected") self.add_button.clicked.connect(self._accept_selection_action) @@ -290,10 +282,10 @@ class KnownNamesFilterDialog(QDialog): self.names_list_widget.clear() current_entries_source = names_to_display if names_to_display is not None else self.all_known_name_entries for entry_obj in current_entries_source: - item = QListWidgetItem(entry_obj['name']) # Display the 'name' (folder name) + item = QListWidgetItem(entry_obj['name']) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(Qt.Unchecked) - item.setData(Qt.UserRole, entry_obj) # Store the full entry object + item.setData(Qt.UserRole, entry_obj) self.names_list_widget.addItem(item) def _filter_list_display(self): @@ -301,7 +293,6 @@ class KnownNamesFilterDialog(QDialog): if not search_text: self._populate_list_widget() return - # Filter based on the 'name' field of each entry object filtered_entries = [ entry_obj for entry_obj in self.all_known_name_entries if search_text in entry_obj['name'].lower() ] @@ -312,7 +303,7 @@ class KnownNamesFilterDialog(QDialog): for i in range(self.names_list_widget.count()): item = self.names_list_widget.item(i) if item.checkState() == Qt.Checked: - self.selected_entries_to_return.append(item.data(Qt.UserRole)) # Get the stored entry object + self.selected_entries_to_return.append(item.data(Qt.UserRole)) self.accept() def _select_all_items(self): @@ -325,21 +316,193 @@ class KnownNamesFilterDialog(QDialog): for i in range(self.names_list_widget.count()): self.names_list_widget.item(i).setCheckState(Qt.Unchecked) - def get_selected_entries(self): # Renamed method + def get_selected_entries(self): return self.selected_entries_to_return +class FavoriteArtistsDialog(QDialog): + """Dialog to display and select favorite artists.""" + def __init__(self, parent_app, cookies_config): + super().__init__(parent_app) + self.parent_app = parent_app + self.cookies_config = cookies_config + self.all_fetched_artists = [] + self.selected_artist_urls = [] + + self.setWindowTitle("Favorite Artists") + self.setModal(True) + self.setMinimumSize(500, 600) + if hasattr(self.parent_app, 'get_dark_theme'): + self.setStyleSheet(self.parent_app.get_dark_theme()) + + self._init_ui() + self._fetch_favorite_artists() + + def _init_ui(self): + main_layout = QVBoxLayout(self) + + self.status_label = QLabel("Loading favorite artists...") + self.status_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(self.status_label) + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search artists...") + self.search_input.textChanged.connect(self._filter_artist_list_display) + main_layout.addWidget(self.search_input) + + self.artist_list_widget = QListWidget() + self.artist_list_widget.setStyleSheet(""" + QListWidget::item { + border-bottom: 1px solid #4A4A4A; /* Slightly softer line */ + padding-top: 4px; + padding-bottom: 4px; + }""") + main_layout.addWidget(self.artist_list_widget) + + combined_buttons_layout = QHBoxLayout() + + self.select_all_button = QPushButton("Select All") + self.select_all_button.clicked.connect(self._select_all_items) + combined_buttons_layout.addWidget(self.select_all_button) + + self.deselect_all_button = QPushButton("Deselect All") + self.deselect_all_button.clicked.connect(self._deselect_all_items) + combined_buttons_layout.addWidget(self.deselect_all_button) + + + self.download_button = QPushButton("Download Selected") + self.download_button.clicked.connect(self._accept_selection_action) + self.download_button.setEnabled(False) + self.download_button.setDefault(True) + combined_buttons_layout.addWidget(self.download_button) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + combined_buttons_layout.addWidget(self.cancel_button) + + combined_buttons_layout.addStretch(1) + + main_layout.addLayout(combined_buttons_layout) + + def _logger(self, message): + """Helper to log messages, either to parent app or console.""" + if hasattr(self.parent_app, 'log_signal') and self.parent_app.log_signal: + self.parent_app.log_signal.emit(f"[FavArtistsDialog] {message}") + else: + print(f"[FavArtistsDialog] {message}") + + def _fetch_favorite_artists(self): + fav_url = "https://kemono.su/api/v1/account/favorites?type=artist" + self._logger(f"Attempting to fetch favorite artists from: {fav_url}") + + cookies_dict = prepare_cookies_for_request( + self.cookies_config['use_cookie'], + self.cookies_config['cookie_text'], + self.cookies_config['selected_cookie_file'], + self.cookies_config['app_base_dir'], + self._logger + ) + + if self.cookies_config['use_cookie'] and not cookies_dict: + self.status_label.setText("Error: Cookies enabled but could not be loaded. Cannot fetch favorites.") + self._logger("Error: Cookies enabled but could not be loaded.") + QMessageBox.warning(self, "Cookie Error", "Cookies are enabled, but no valid cookies could be loaded. Please check your cookie settings or file.") + return + + try: + headers = {'User-Agent': 'Mozilla/5.0'} + response = requests.get(fav_url, headers=headers, cookies=cookies_dict, timeout=20) + response.raise_for_status() + + artists_data = response.json() + + if not isinstance(artists_data, list): + self.status_label.setText("Error: API did not return a list of artists.") + self._logger(f"Error: Expected a list from API, got {type(artists_data)}") + QMessageBox.critical(self, "API Error", "The favorite artists API did not return the expected data format (list).") + return + + self.all_fetched_artists = [] + for artist_entry in artists_data: + artist_id = artist_entry.get("id") + artist_name = html.unescape(artist_entry.get("name", "Unknown Artist").strip()) + artist_service = artist_entry.get("service") + + if artist_id and artist_name and artist_service: + full_url = f"https://kemono.su/{artist_service}/user/{artist_id}" + self.all_fetched_artists.append({'name': artist_name, 'url': full_url, 'service': artist_service}) + else: + self._logger(f"Warning: Skipping favorite artist entry due to missing data: {artist_entry}") + + self.all_fetched_artists.sort(key=lambda x: x['name'].lower()) + self._populate_artist_list_widget() + self.status_label.setText(f"{len(self.all_fetched_artists)} favorite artist(s) found.") + self.download_button.setEnabled(len(self.all_fetched_artists) > 0) + + except requests.exceptions.RequestException as e: + self.status_label.setText(f"Error fetching favorites: {e}") + self._logger(f"Error fetching favorites: {e}") + QMessageBox.critical(self, "Fetch Error", f"Could not fetch favorite artists: {e}") + except Exception as e: + self.status_label.setText(f"An unexpected error occurred: {e}") + self._logger(f"Unexpected error: {e}") + QMessageBox.critical(self, "Error", f"An unexpected error occurred: {e}") + + def _populate_artist_list_widget(self, artists_to_display=None): + self.artist_list_widget.clear() + source_list = artists_to_display if artists_to_display is not None else self.all_fetched_artists + for artist_data in source_list: + item = QListWidgetItem(f"{artist_data['name']} ({artist_data.get('service', 'N/A').capitalize()})") # type: ignore + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + item.setData(Qt.UserRole, artist_data) + self.artist_list_widget.addItem(item) + + def _filter_artist_list_display(self): + search_text = self.search_input.text().lower().strip() + if not search_text: + self._populate_artist_list_widget() + return + + filtered_artists = [ + artist for artist in self.all_fetched_artists + if search_text in artist['name'].lower() or search_text in artist['url'].lower() + ] + self._populate_artist_list_widget(filtered_artists) + + def _select_all_items(self): + for i in range(self.artist_list_widget.count()): + self.artist_list_widget.item(i).setCheckState(Qt.Checked) + + def _deselect_all_items(self): + for i in range(self.artist_list_widget.count()): + self.artist_list_widget.item(i).setCheckState(Qt.Unchecked) + + def _accept_selection_action(self): + self.selected_artists_data = [] + for i in range(self.artist_list_widget.count()): + item = self.artist_list_widget.item(i) + if item.checkState() == Qt.Checked: + self.selected_artists_data.append(item.data(Qt.UserRole)) + + if not self.selected_artists_data: + QMessageBox.information(self, "No Selection", "Please select at least one artist to download.") + return + self.accept() + + def get_selected_artists(self): + return self.selected_artists_data + class HelpGuideDialog(QDialog): """A multi-page dialog for displaying the feature guide.""" def __init__(self, steps_data, parent=None): super().__init__(parent) self.current_step = 0 - self.steps_data = steps_data # List of (title, content_html) tuples + self.steps_data = steps_data self.setWindowTitle("Kemono Downloader - Feature Guide") self.setModal(True) - self.setFixedSize(650, 600) # Adjusted size for guide content + self.setFixedSize(650, 600) - # Apply similar styling to TourDialog, or a distinct one if preferred self.setStyleSheet(parent.get_dark_theme() if hasattr(parent, 'get_dark_theme') else """ QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; } QLabel { color: #E0E0E0; } @@ -348,7 +511,7 @@ class HelpGuideDialog(QDialog): QPushButton:pressed { background-color: #4A4A4A; } """) self._init_ui() - if parent: # Attempt to center on parent + if parent: self.move(parent.geometry().center() - self.rect().center()) def _init_ui(self): @@ -359,9 +522,9 @@ class HelpGuideDialog(QDialog): self.stacked_widget = QStackedWidget() main_layout.addWidget(self.stacked_widget, 1) - self.tour_steps_widgets = [] # To hold TourStepWidget instances + self.tour_steps_widgets = [] for title, content in self.steps_data: - step_widget = TourStepWidget(title, content) # Reuse TourStepWidget + step_widget = TourStepWidget(title, content) self.tour_steps_widgets.append(step_widget) self.stacked_widget.addWidget(step_widget) @@ -373,16 +536,9 @@ class HelpGuideDialog(QDialog): self.back_button.clicked.connect(self._previous_step) self.back_button.setEnabled(False) - # Determine base directory for assets - # This logic assumes 'assest' folder is at the same level as main.py or the executable if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - # For PyInstaller, assets are in _MEIPASS or a relative path from executable - # If 'assest' is bundled at the root of _MEIPASS: assets_base_dir = sys._MEIPASS - # If 'assest' is bundled relative to the executable directory: - # assets_base_dir = os.path.dirname(sys.executable) else: - # For development, assets are relative to the script assets_base_dir = os.path.dirname(os.path.abspath(__file__)) github_icon_path = os.path.join(assets_base_dir, "assets", "github.png") @@ -393,8 +549,7 @@ class HelpGuideDialog(QDialog): self.instagram_button = QPushButton(QIcon(instagram_icon_path), "") self.Discord_button = QPushButton(QIcon(discord_icon_path), "") - # Optional: Set a fixed icon size for consistency - icon_size = QSize(24, 24) # Adjust as needed + icon_size = QSize(24, 24) self.github_button.setIconSize(icon_size) self.instagram_button.setIconSize(icon_size) self.Discord_button.setIconSize(icon_size) @@ -410,35 +565,31 @@ class HelpGuideDialog(QDialog): self.Discord_button.setToolTip("Visit our Discord community (Opens in browser)") - # Social media buttons layout social_layout = QHBoxLayout() social_layout.setSpacing(10) social_layout.addWidget(self.github_button) social_layout.addWidget(self.instagram_button) social_layout.addWidget(self.Discord_button) - # social_layout.addStretch(1) # Pushes social buttons to the left if uncommented and placed before nav buttons - # Add social buttons to the main buttons_layout, before the stretch, to keep them left - # Clear buttons_layout and rebuild to ensure order while buttons_layout.count(): - item = buttons_layout.takeAt(0) # Removes the item from the layout - if item.widget(): # Check if the item is a widget - item.widget().setParent(None) # Detach the widget from this layout - elif item.layout(): # If it's a sub-layout - pass # Sub-layouts are handled by Qt's ownership or need explicit deletion if complex - buttons_layout.addLayout(social_layout) # Add social buttons on the left - buttons_layout.addStretch(1) # Stretch between social and nav buttons - buttons_layout.addWidget(self.back_button) # Back and Next on the right + item = buttons_layout.takeAt(0) + if item.widget(): + item.widget().setParent(None) + elif item.layout(): + pass + buttons_layout.addLayout(social_layout) + buttons_layout.addStretch(1) + buttons_layout.addWidget(self.back_button) buttons_layout.addWidget(self.next_button) main_layout.addLayout(buttons_layout) - self._update_button_states() # Set initial button states + self._update_button_states() def _next_step_action(self): if self.current_step < len(self.tour_steps_widgets) - 1: self.current_step += 1 self.stacked_widget.setCurrentIndex(self.current_step) - else: # Last page - self.accept() # Close dialog + else: + self.accept() self._update_button_states() def _previous_step(self): @@ -455,15 +606,12 @@ class HelpGuideDialog(QDialog): self.back_button.setEnabled(self.current_step > 0) def _open_github_link(self): - # Replace with your actual GitHub project URL QDesktopServices.openUrl(QUrl("https://github.com/Yuvi9587")) def _open_instagram_link(self): - # Replace with your actual Instagram URL QDesktopServices.openUrl(QUrl("https://www.instagram.com/uvi.arts/")) def _open_Discord_link(self): - # Replace with your actual Discord URL QDesktopServices.openUrl(QUrl("https://discord.gg/BqP64XTdJN")) class TourStepWidget(QWidget): @@ -472,18 +620,18 @@ class TourStepWidget(QWidget): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(10) # Adjusted spacing between title and content for bullet points + layout.setSpacing(10) title_label = QLabel(title_text) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;") layout.addWidget(title_label) scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) # Important for the content_label to resize correctly - scroll_area.setFrameShape(QFrame.NoFrame) # Make it look seamless with the dialog - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Content is word-wrapped - scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # Show scrollbar only when needed - scroll_area.setStyleSheet("background-color: transparent;") # Match dialog background + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.NoFrame) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll_area.setStyleSheet("background-color: transparent;") content_label = QLabel(content_text) content_label.setWordWrap(True) @@ -491,7 +639,7 @@ class TourStepWidget(QWidget): content_label.setTextFormat(Qt.RichText) content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;") scroll_area.setWidget(content_label) - layout.addWidget(scroll_area, 1) # The '1' is a stretch factor + layout.addWidget(scroll_area, 1) class TourDialog(QDialog): @@ -503,9 +651,9 @@ class TourDialog(QDialog): tour_finished_normally = pyqtSignal() tour_skipped = pyqtSignal() - CONFIG_ORGANIZATION_NAME = "KemonoDownloader" # Shared with main app for consistency if needed, but can be distinct - CONFIG_APP_NAME_TOUR = "ApplicationTour" # Specific QSettings group for tour - TOUR_SHOWN_KEY = "neverShowTourAgainV5" # Updated key to re-show tour + CONFIG_ORGANIZATION_NAME = "KemonoDownloader" + CONFIG_APP_NAME_TOUR = "ApplicationTour" + TOUR_SHOWN_KEY = "neverShowTourAgainV6" def __init__(self, parent=None): super().__init__(parent) @@ -514,7 +662,7 @@ class TourDialog(QDialog): self.setWindowTitle("Welcome to Kemono Downloader!") self.setModal(True) - self.setFixedSize(600, 620) # Slightly adjusted for potentially more text + self.setFixedSize(600, 620) self.setStyleSheet(""" QDialog { background-color: #2E2E2E; @@ -557,7 +705,7 @@ class TourDialog(QDialog): primary_screen = QApplication.primaryScreen() if not primary_screen: screens = QApplication.screens() - if not screens: return # Cannot center + if not screens: return primary_screen = screens[0] available_geo = primary_screen.availableGeometry() @@ -582,8 +730,8 @@ class TourDialog(QDialog): "" ) - self.step5 = TourStepWidget("β‘£ Organization & Performance", step5_content) + self.step5_organization = TourStepWidget("β‘€ Organization & Performance", step5_content) step6_errors_content = ( "Sometimes, downloads might encounter issues. Here are a few common ones:" "" ) self.step6_errors = TourStepWidget("β‘₯ Common Errors & Troubleshooting", step6_errors_content) - + step7_final_controls_content = ( "Monitoring and Controls:" "