This commit is contained in:
Yuvi9587
2025-05-29 08:50:01 +01:00
parent 8137c76eb4
commit 78357df07f
2 changed files with 208 additions and 444 deletions

File diff suppressed because it is too large Load Diff

329
main.py
View File

@@ -329,8 +329,8 @@ class FavoriteArtistsDialog(QDialog):
self.selected_artist_urls = []
self.setWindowTitle("Favorite Artists")
self.setModal(True)
self.setMinimumSize(500, 600)
self.setModal(True) # type: ignore
self.setMinimumSize(500, 500) # Reduced minimum height
if hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
@@ -349,6 +349,7 @@ class FavoriteArtistsDialog(QDialog):
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 {
@@ -357,7 +358,14 @@ class FavoriteArtistsDialog(QDialog):
padding-bottom: 4px;
}""")
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)
self.status_label.setText("⏳ Loading favorite artists...") # Initial loading message
self.status_label.setAlignment(Qt.AlignCenter)
combined_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton("Select All")
@@ -390,6 +398,11 @@ class FavoriteArtistsDialog(QDialog):
else:
print(f"[FavArtistsDialog] {message}")
def _show_content_elements(self, show):
"""Helper to show/hide content-related widgets."""
self.search_input.setVisible(show)
self.artist_list_widget.setVisible(show)
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}")
@@ -404,8 +417,10 @@ class FavoriteArtistsDialog(QDialog):
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._show_content_elements(False)
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.")
self.download_button.setEnabled(False)
return
try:
@@ -417,6 +432,7 @@ class FavoriteArtistsDialog(QDialog):
if not isinstance(artists_data, list):
self.status_label.setText("Error: API did not return a list of artists.")
self._show_content_elements(False)
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
@@ -435,17 +451,28 @@ class FavoriteArtistsDialog(QDialog):
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)
if self.all_fetched_artists:
self.status_label.setText(f"Found {len(self.all_fetched_artists)} favorite artist(s).")
self._show_content_elements(True)
self.download_button.setEnabled(True)
else:
self.status_label.setText("No favorite artists found.")
self._show_content_elements(False)
self.download_button.setEnabled(False)
except requests.exceptions.RequestException as e:
self.status_label.setText(f"Error fetching favorites: {e}")
self._show_content_elements(False)
self._logger(f"Error fetching favorites: {e}")
QMessageBox.critical(self, "Fetch Error", f"Could not fetch favorite artists: {e}")
self.download_button.setEnabled(False)
except Exception as e:
self.status_label.setText(f"An unexpected error occurred: {e}")
self._show_content_elements(False)
self._logger(f"Unexpected error: {e}")
QMessageBox.critical(self, "Error", f"An unexpected error occurred: {e}")
self.download_button.setEnabled(False)
def _populate_artist_list_widget(self, artists_to_display=None):
self.artist_list_widget.clear()
@@ -498,12 +525,11 @@ class FavoritePostsFetcherThread(QThread):
progress_bar_update = pyqtSignal(int, int) # value, maximum
finished = pyqtSignal(list, str) # list of posts, error message (or None)
def __init__(self, cookies_config, parent_logger_func, parent_get_domain_func):
def __init__(self, cookies_config, parent_logger_func): # Removed parent_get_domain_func
super().__init__()
self.cookies_config = cookies_config
self.parent_logger_func = parent_logger_func
self.parent_get_domain_func = parent_get_domain_func
self.cancellation_event = threading.Event() # For potential future cancellation
self.cancellation_event = threading.Event()
def _logger(self, message):
self.parent_logger_func(f"[FavPostsFetcherThread] {message}")
@@ -553,53 +579,9 @@ class FavoritePostsFetcherThread(QThread):
else:
self._logger(f"Warning: Skipping favorite post entry due to missing data: {post_entry}")
unique_creators = {}
for post_data in all_fetched_posts_temp:
creator_key = (post_data['service'], post_data['creator_id'])
if creator_key not in unique_creators:
unique_creators[creator_key] = None
creator_name_cache_local = {}
if unique_creators:
self.status_update.emit(f"Found {len(all_fetched_posts_temp)} posts. Fetching {len(unique_creators)} unique creator names...")
self.progress_bar_update.emit(0, len(unique_creators)) # Set max for creator name fetching
fetched_names_count = 0
total_unique_creators = len(unique_creators)
for (service, creator_id_val) in unique_creators.keys():
if self.cancellation_event.is_set():
self.finished.emit([], "Fetching cancelled.")
return
creator_api_url = f"https://{self.parent_get_domain_func(service)}/api/v1/{service}/user/{creator_id_val}?o=0"
try:
creator_response = requests.get(creator_api_url, headers=headers, cookies=cookies_dict, timeout=10)
creator_response.raise_for_status()
creator_info_list = creator_response.json()
if isinstance(creator_info_list, list) and creator_info_list:
creator_name_from_api = creator_info_list[0].get("user_name")
creator_name = html.unescape(creator_name_from_api.strip()) if creator_name_from_api else creator_id_val
creator_name_cache_local[(service, creator_id_val)] = creator_name
fetched_names_count += 1
self.status_update.emit(f"Fetched {fetched_names_count}/{total_unique_creators} creator names...")
self.progress_bar_update.emit(fetched_names_count, total_unique_creators)
else:
self._logger(f"Warning: Could not get name for {service}/{creator_id_val}. API response not a list or empty.")
creator_name_cache_local[(service, creator_id_val)] = creator_id_val
time.sleep(0.1) # Be polite
except requests.exceptions.RequestException as e_creator:
self._logger(f"Error fetching name for {service}/{creator_id_val}: {e_creator}")
creator_name_cache_local[(service, creator_id_val)] = creator_id_val # Fallback
except Exception as e_gen_creator:
self._logger(f"Unexpected error fetching name for {service}/{creator_id_val}: {e_gen_creator}")
creator_name_cache_local[(service, creator_id_val)] = creator_id_val # Fallback
for post_data in all_fetched_posts_temp:
post_data['creator_name'] = creator_name_cache_local.get(
(post_data['service'], post_data['creator_id']), post_data['creator_id']
)
all_fetched_posts_temp.sort(key=lambda x: x.get('added_date', ''), reverse=True)
# 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', '')), reverse=False)
self.finished.emit(all_fetched_posts_temp, None)
except requests.exceptions.RequestException as e:
@@ -627,25 +609,27 @@ class PostListItemWidget(QWidget):
self.layout.addWidget(self.info_label, 1)
self._setup_display_text()
def _setup_display_text(self):
creator_display = self.post_data.get('creator_name', self.post_data.get('creator_id', 'N/A'))
post_title_text = self.post_data.get('title', 'Untitled Post')
suffix_plain = self.post_data.get('suffix_for_display', "") # Changed from prefix_for_display
title_plain = self.post_data.get('title', 'Untitled Post')
known_char_name = self.parent_dialog._find_known_character_in_title(post_title_text)
known_line_text = f"Known - {known_char_name}" if known_char_name else "Known - "
service_val = self.post_data.get('service', 'N/A').capitalize()
added_date_str = self.post_data.get('added_date', 'N/A')
added_date_formatted = added_date_str.split('T')[0] if added_date_str and 'T' in added_date_str else added_date_str
details_line_text = f"{service_val} - Added: {added_date_formatted}"
# Escape them for HTML display
escaped_suffix = html.escape(suffix_plain) # Changed from escaped_prefix
escaped_title = html.escape(title_plain)
line1_html = f"<b>{html.escape(creator_display)} - {html.escape(post_title_text)}</b>"
line2_html = html.escape(known_line_text)
line3_html = html.escape(details_line_text)
display_html = f"{line1_html}<br>{line2_html}<br>{line3_html}"
self.info_label.setText(display_html)
# 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)
def isChecked(self): return self.checkbox.isChecked()
def setCheckState(self, state): self.checkbox.setCheckState(state)
@@ -660,11 +644,12 @@ class FavoritePostsDialog(QDialog):
self.all_fetched_posts = []
self.selected_posts_data = []
self.known_names_list_ref = known_names_list_ref # Store reference to global KNOWN_NAMES
self.displayable_grouped_posts = {} # For storing posts grouped by artist
self.fetcher_thread = None # For the worker thread
self.setWindowTitle("Favorite Posts")
self.setModal(True)
self.setMinimumSize(600, 600) # Slightly wider for post titles
self.setWindowTitle("Favorite Posts") # type: ignore
self.setModal(True) # type: ignore
self.setMinimumSize(600, 600) # Reduced minimum height
if hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
@@ -695,6 +680,7 @@ class FavoritePostsDialog(QDialog):
padding-top: 4px;
padding-bottom: 4px;
}""")
self.post_list_widget.setAlternatingRowColors(True)
main_layout.addWidget(self.post_list_widget)
combined_buttons_layout = QHBoxLayout()
@@ -731,11 +717,10 @@ class FavoritePostsDialog(QDialog):
self.fetcher_thread = FavoritePostsFetcherThread(
self.cookies_config,
self.parent_app.log_signal.emit, # Pass parent's logger
self._get_domain_for_service # Pass method reference
)
self.fetcher_thread.progress_bar_update.connect(self._set_progress_bar_value)
) # Removed _get_domain_for_service
self.fetcher_thread.status_update.connect(self.status_label.setText)
self.fetcher_thread.finished.connect(self._on_fetch_completed)
self.fetcher_thread.progress_bar_update.connect(self._set_progress_bar_value) # Connect the missing signal
self.progress_bar.setVisible(True)
self.fetcher_thread.start()
@@ -757,94 +742,142 @@ class FavoritePostsDialog(QDialog):
self.progress_bar.setVisible(False)
self.all_fetched_posts = fetched_posts_list
self._populate_post_list_widget()
self._populate_post_list_widget() # This will now group and display
self.status_label.setText(f"{len(self.all_fetched_posts)} favorite post(s) found.")
self.download_button.setEnabled(len(self.all_fetched_posts) > 0)
if self.fetcher_thread:
self.fetcher_thread.quit()
self.fetcher_thread.wait()
self.fetcher_thread = None
def _get_domain_for_service(self, service_name):
# Basic heuristic, might need refinement if more domains are supported
if service_name and "coomer" in service_name.lower(): # e.g. if service is 'coomer_onlyfans'
return "coomer.su" # Or coomer.party
return "kemono.su" # Default
def _find_known_character_in_title(self, post_title):
if not post_title or not self.known_names_list_ref:
def _find_best_known_name_match_in_title(self, title_raw):
if not title_raw or not self.known_names_list_ref:
return None
# Sort by length of primary name to prioritize more specific matches.
sorted_known_names = sorted(self.known_names_list_ref, key=lambda x: len(x.get("name", "")), reverse=True)
for known_entry in sorted_known_names:
aliases_to_check = known_entry.get("aliases", [])
if not aliases_to_check and known_entry.get("name"):
aliases_to_check = [known_entry.get("name")]
title_lower = title_raw.lower()
best_match_known_name_primary = None
longest_match_len = 0
for alias in aliases_to_check:
if not alias:
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
pattern = r"(?i)\b" + re.escape(alias) + r"\b"
if re.search(pattern, post_title):
return known_entry.get("name")
return None
# 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
def _populate_post_list_widget(self, posts_to_display=None):
self.post_list_widget.clear()
source_list = posts_to_display if posts_to_display is not None else self.all_fetched_posts
for post_data in source_list:
creator_display = post_data.get('creator_name', post_data.get('creator_id', 'N/A')) # Use creator_name
post_title_text = post_data.get('title', 'Untitled Post')
# The HTML generation is now inside PostListItemWidget
source_list_for_grouping = posts_to_display if posts_to_display is not None else self.all_fetched_posts
list_item = QListWidgetItem(self.post_list_widget) # Parent it to the list widget
custom_widget = PostListItemWidget(post_data, self) # Pass self (FavoritePostsDialog)
list_item.setSizeHint(custom_widget.sizeHint()) # Set size hint for the QListWidgetItem
list_item.setData(Qt.UserRole, post_data)
self.post_list_widget.addItem(list_item)
self.post_list_widget.setItemWidget(list_item, custom_widget) # Set the custom widget
# Group posts by (service, creator_id)
grouped_posts = {}
for post in source_list_for_grouping:
service = post.get('service', 'unknown_service')
creator_id = post.get('creator_id', 'unknown_id')
group_key = (service, creator_id) # Use tuple as key
if group_key not in grouped_posts:
grouped_posts[group_key] = []
grouped_posts[group_key].append(post)
sorted_group_keys = sorted(grouped_posts.keys(), key=lambda x: (x[0].lower(), x[1].lower()))
self.displayable_grouped_posts = {
key: sorted(grouped_posts[key], key=lambda p: p.get('added_date', ''), reverse=True)
for key in sorted_group_keys
}
for service, creator_id_val in sorted_group_keys:
artist_name = f"{service.capitalize()} / {creator_id_val}" # Display service and ID
# Add artist header item
artist_header_item = QListWidgetItem(f"🎨 {artist_name}")
artist_header_item.setFlags(Qt.NoItemFlags) # Not selectable, not checkable
font = artist_header_item.font()
font.setBold(True)
font.setPointSize(font.pointSize() + 1) # Make it a bit larger
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
if found_known_name_primary:
suffix_text = f" [Known - {found_known_name_primary}]" # Changed to suffix format
post_data['suffix_for_display'] = suffix_text # Store as suffix_for_display
plain_text_title_for_list_item = post_title_raw + suffix_text # Append suffix
else:
post_data.pop('suffix_for_display', None) # Ensure suffix key is removed if no match
list_item = QListWidgetItem(self.post_list_widget) # Parent it
list_item.setText(plain_text_title_for_list_item) # Use plain text (possibly prefixed)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
list_item.setCheckState(Qt.Unchecked)
list_item.setData(Qt.UserRole, post_data) # Store full data for this post
self.post_list_widget.addItem(list_item)
def _filter_post_list_display(self):
search_text = self.search_input.text().lower().strip()
if not search_text:
self._populate_post_list_widget()
self._populate_post_list_widget(self.all_fetched_posts) # Repopulate with all, which will group
return
filtered_posts = [
post for post in self.all_fetched_posts
if search_text in post['title'].lower() or \
search_text in post.get('creator_name', post.get('creator_id', '')).lower() or \
search_text in post['service'].lower()
]
self._populate_post_list_widget(filtered_posts)
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 = False # Creator name is no longer fetched
matches_creator_id = search_text in post.get('creator_id', '').lower()
matches_service = search_text in post['service'].lower()
if matches_post_title or matches_creator_name or matches_creator_id or matches_service:
filtered_posts_to_group.append(post)
self._populate_post_list_widget(filtered_posts_to_group) # Repopulate with filtered, which will group
def _select_all_items(self):
for i in range(self.post_list_widget.count()):
item = self.post_list_widget.item(i)
widget = self.post_list_widget.itemWidget(item)
if widget and hasattr(widget, 'setCheckState'):
widget.setCheckState(Qt.Checked)
if item and item.flags() & Qt.ItemIsUserCheckable: # Only check actual post items
item.setCheckState(Qt.Checked)
def _deselect_all_items(self):
for i in range(self.post_list_widget.count()):
item = self.post_list_widget.item(i)
widget = self.post_list_widget.itemWidget(item)
if widget and hasattr(widget, 'setCheckState'):
widget.setCheckState(Qt.Unchecked)
if item and item.flags() & Qt.ItemIsUserCheckable: # Only uncheck actual post items
item.setCheckState(Qt.Unchecked)
def _accept_selection_action(self):
self.selected_posts_data = []
for i in range(self.post_list_widget.count()):
item = self.post_list_widget.item(i)
widget = self.post_list_widget.itemWidget(item) # Get the custom widget
if widget and hasattr(widget, 'isChecked') and widget.isChecked():
# Retrieve post_data from the custom widget or the item's UserRole
post_data_for_download = widget.get_post_data() if hasattr(widget, 'get_post_data') else item.data(Qt.UserRole)
if item and item.checkState() == Qt.Checked:
post_data_for_download = item.data(Qt.UserRole)
self.selected_posts_data.append(post_data_for_download)
if not self.selected_posts_data:
@@ -855,6 +888,7 @@ class FavoritePostsDialog(QDialog):
def get_selected_posts(self):
return self.selected_posts_data
class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide."""
def __init__(self, steps_data, parent=None):
@@ -1543,7 +1577,19 @@ class DownloaderApp(QWidget):
self.log_signal.emit(" Local API server functionality has been removed.")
self.log_signal.emit(" 'Skip Current File' button has been removed.")
if hasattr(self, 'character_input'):
self.character_input.setToolTip("Names, comma-separated. Group aliases: (alias1, alias2, alias3) becomes folder name 'alias1 alias2 alias3' (after cleaning).\nAll names in the group are used as aliases for matching.\nE.g., yor, (Boa, Hancock, Snake Princess)")
self.character_input.setToolTip("Enter character names (comma-separated). Supports advanced grouping and affects folder naming "
"if 'Separate Folders' is enabled.\n\n"
"Examples:\n"
"- Nami → Matches 'Nami', creates folder 'Nami'.\n"
"- (Ulti, Vivi) → Matches either, folder 'Ulti Vivi', adds both to Known.txt separately.\n"
"- (Boa, Hancock)~ → Matches either, folder 'Boa Hancock', adds as one group in Known.txt.\n\n"
"Names are treated as aliases for matching.\n\n"
"Filter Modes (button cycles):\n"
"- Files: Filters by filename.\n"
"- Title: Filters by post title.\n"
"- Both: Title first, then filename.\n"
"- Comments (Beta): Filename first, then post comments."
)
self.log_signal.emit(f" Manga filename style loaded: '{self.manga_filename_style}'")
self.log_signal.emit(f" Skip words scope loaded: '{self.skip_words_scope}'")
self.log_signal.emit(f" Character filter scope set to default: '{self.char_filter_scope}'")
@@ -1949,10 +1995,19 @@ class DownloaderApp(QWidget):
self.character_input = QLineEdit()
self.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)")
self.character_input.setToolTip(
self._get_tooltip_for_character_input()
"Enter character names, comma-separated (e.g., Tifa, Aerith).\n"
"Group aliases for a combined folder name: (alias1, alias2, alias3) becomes folder 'alias1 alias2 alias3'.\n"
"All names in the group are used as aliases for matching content.\n\n"
"The 'Filter: [Type]' button next to this input cycles how this filter applies:\n"
"- Filter: Files: Checks individual filenames. Only matching files are downloaded.\n"
"- Filter: Title: Checks post titles. All files from a matching post are downloaded.\n"
"- Filter: Both: Checks post title first. If no match, then checks filenames.\n"
"- Filter: Comments (Beta): Checks filenames first. If no match, then checks post comments.\n\n"
"This filter also influences folder naming if 'Separate Folders by Name/Title' is enabled."
)
char_input_and_button_layout.addWidget(self.character_input, 3)
self.char_filter_scope_toggle_button = QPushButton()
self._update_char_filter_scope_button_text()
self.char_filter_scope_toggle_button.setStyleSheet("padding: 6px 10px;")
@@ -1998,13 +2053,17 @@ 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 files or posts.\n"
"The 'Scope' button determines if this applies to file names, post titles, or both.\n"
"Example: WIP, sketch, preview, text post"
"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"
"- Scope: Files: Skips individual files if their names contain any of these words.\n"
"- Scope: Posts: Skips entire posts if their titles contain any of these words.\n"
"- Scope: Both: Applies both (post title first, then individual files if post title is okay)."
)
self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
skip_input_and_button_layout.addWidget(self.skip_words_input, 1)
self.skip_scope_toggle_button = QPushButton()
self._update_skip_scope_button_text()
self.skip_scope_toggle_button.setStyleSheet("padding: 6px 10px;")