diff --git a/downloader_utils.py b/downloader_utils.py
index d905902..7ffd68f 100644
--- a/downloader_utils.py
+++ b/downloader_utils.py
@@ -372,6 +372,9 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
err_msg = f"Error fetching offset {offset} from {paginated_url}: {e}"
if e.response is not None:
err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})"
+ if isinstance(e, requests.exceptions.ConnectionError) and \
+ ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
+ err_msg += "\n đĄ This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN."
raise RuntimeError(err_msg)
except ValueError as e:
raise RuntimeError(f"Error decoding JSON from offset {offset} ({paginated_url}): {e}. Response text: {response.text[:200]}")
@@ -407,6 +410,9 @@ def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger,
err_msg = f"Error fetching comments for post {post_id} from {comments_api_url}: {e}"
if e.response is not None:
err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})"
+ if isinstance(e, requests.exceptions.ConnectionError) and \
+ ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
+ err_msg += "\n đĄ This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN."
raise RuntimeError(err_msg)
except ValueError as e: # JSONDecodeError inherits from ValueError
raise RuntimeError(f"Error decoding JSON from comments API for post {post_id} ({comments_api_url}): {e}. Response text: {response.text[:200]}")
@@ -422,27 +428,65 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
logger(" Download_from_api cancelled at start.")
return
+ # --- Moved Up: Parse api_domain and prepare cookies early ---
+ parsed_input_url_for_domain = urlparse(api_url_input)
+ api_domain = parsed_input_url_for_domain.netloc
+ if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
+ logger(f"â ī¸ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
+ api_domain = "kemono.su" # Default domain if input is unusual
+
+ cookies_for_api = None
+ if use_cookie and app_base_dir: # app_base_dir is needed for cookies.txt path
+ cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger)
+ # --- End Moved Up ---
+
+ # --- New: Attempt direct fetch for specific post URL first ---
+ if target_post_id:
+ direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
+ logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
+ try:
+ direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
+ direct_response.raise_for_status()
+ direct_post_data = direct_response.json()
+
+ # The direct endpoint might return a single post object or a list containing one post.
+ # Check if it's a list and take the first item, or use the object directly.
+ if isinstance(direct_post_data, list) and direct_post_data:
+ direct_post_data = direct_post_data[0]
+
+ # Check if the data is a dict and contains a 'post' key (new format)
+ if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
+ direct_post_data = direct_post_data['post'] # Extract the nested post data
+
+ if isinstance(direct_post_data, dict) and direct_post_data.get('id') == target_post_id: # Now check the extracted/direct dict
+ logger(f" â
Direct fetch successful for post {target_post_id}.")
+ yield [direct_post_data] # Yield the single post data as a list
+ return # Exit the generator, no need to paginate
+ else:
+ # Log more details about the unexpected response
+ response_type = type(direct_post_data).__name__
+ response_snippet = str(direct_post_data)[:200] # Log first 200 chars
+ logger(f" â ī¸ Direct fetch for post {target_post_id} returned unexpected data (Type: {response_type}, Snippet: '{response_snippet}'). Falling back to pagination.")
+ except requests.exceptions.RequestException as e:
+ logger(f" â ī¸ Direct fetch failed for post {target_post_id}: {e}. Falling back to pagination.")
+ except Exception as e:
+ logger(f" â ī¸ Unexpected error during direct fetch for post {target_post_id}: {e}. Falling back to pagination.")
+ # --- End New: Attempt direct fetch ---
+
if not service or not user_id:
logger(f"â Invalid URL or could not extract service/user: {api_url_input}")
return
if target_post_id and (start_page or end_page):
logger("â ī¸ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
- start_page = end_page = None
+ # start_page = end_page = None # Keep these potentially for the fallback pagination
is_creator_feed_for_manga = manga_mode and not target_post_id
- parsed_input = urlparse(api_url_input)
- api_domain = parsed_input.netloc
- if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
- logger(f"â ī¸ Unrecognized domain '{api_domain}'. Defaulting to kemono.su for API calls.")
- api_domain = "kemono.su"
+ # api_domain is already parsed and validated above
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
- cookies_for_api = None
- if use_cookie and app_base_dir: # app_base_dir is needed for cookies.txt path
- cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger)
page_size = 50
@@ -544,7 +588,7 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
current_offset = 0
processed_target_post_flag = False
- if start_page and start_page > 1 and not target_post_id:
+ if start_page and start_page > 1 and not target_post_id: # Only apply start_page if not targeting a specific post directly
current_offset = (start_page - 1) * page_size
current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
@@ -586,11 +630,11 @@ def download_from_api(api_url_input, logger=print, start_page=None, end_page=Non
break
if not posts_batch:
- if target_post_id and not processed_target_post_flag:
+ if target_post_id and not processed_target_post_flag: # Only log this if we were searching for a specific post
logger(f"â Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
elif not target_post_id:
if current_page_num == (start_page or 1):
- logger(f"đ No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
+ logger(f"đ No posts found on the first page checked (page {current_page_num}, offset {current_offset}).") # Log if the very first page of the range/feed is empty
else:
logger(f"â
Reached end of posts (no more content from API at offset {current_offset}).")
break
@@ -1011,10 +1055,15 @@ class PostProcessorWorker:
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
self.logger(f" â Download Error (Retryable): {api_original_filename}. Error: {e}")
last_exception_for_retry_later = e # Store this specific exception
+ if isinstance(e, requests.exceptions.ConnectionError) and \
+ ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
+ self.logger(" đĄ This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
if 'file_content_buffer' in locals() and file_content_buffer: file_content_buffer.close()
except requests.exceptions.RequestException as e:
self.logger(f" â Download Error (Non-Retryable): {api_original_filename}. Error: {e}")
last_exception_for_retry_later = e # Store this too
+ if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): # More general check
+ self.logger(" đĄ This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
if 'file_content_buffer' in locals() and file_content_buffer: file_content_buffer.close(); break
except Exception as e:
self.logger(f" â Unexpected Download Error: {api_original_filename}: {e}\n{traceback.format_exc(limit=2)}")
diff --git a/main.py b/main.py
index 90d4f76..14a67fc 100644
--- a/main.py
+++ b/main.py
@@ -23,7 +23,7 @@ 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, QSizePolicy,
+ QDialog, QStackedWidget, QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar,
QAbstractItemView,
QFrame,
QAbstractButton
@@ -492,6 +492,369 @@ class FavoriteArtistsDialog(QDialog):
def get_selected_artists(self):
return self.selected_artists_data
+class FavoritePostsFetcherThread(QThread):
+ """Worker thread to fetch favorite posts and creator names."""
+ status_update = pyqtSignal(str)
+ 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):
+ 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
+
+ def _logger(self, message):
+ self.parent_logger_func(f"[FavPostsFetcherThread] {message}")
+
+ def run(self):
+ fav_url = "https://kemono.su/api/v1/account/favorites?type=post"
+ self._logger(f"Attempting to fetch favorite posts from: {fav_url}")
+ self.status_update.emit("Fetching list of favorite posts...")
+ self.progress_bar_update.emit(0, 0) # Indeterminate state for initial fetch
+
+ 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.finished.emit([], "Error: Cookies enabled but could not be loaded.")
+ return
+
+ try:
+ headers = {'User-Agent': 'Mozilla/5.0'}
+ response = requests.get(fav_url, headers=headers, cookies=cookies_dict, timeout=20)
+ response.raise_for_status()
+ posts_data_from_api = response.json()
+
+ 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")
+ post_title = html.unescape(post_entry.get("title", "Untitled Post").strip())
+ service = post_entry.get("service")
+ creator_id = post_entry.get("user")
+ added_date_str = post_entry.get("added", post_entry.get("published", ""))
+
+ if post_id and post_title and service and creator_id:
+ all_fetched_posts_temp.append({
+ 'post_id': post_id, 'title': post_title, 'service': service,
+ 'creator_id': creator_id, 'added_date': added_date_str
+ })
+ 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)
+ self.finished.emit(all_fetched_posts_temp, None)
+
+ except requests.exceptions.RequestException as e:
+ self.finished.emit([], f"Error fetching favorite posts: {e}")
+ except Exception as e:
+ self.finished.emit([], f"An unexpected error occurred: {e}")
+
+class PostListItemWidget(QWidget):
+ """Custom widget for displaying a single post in the FavoritePostsDialog list."""
+ def __init__(self, post_data_dict, parent_dialog_ref, parent=None):
+ super().__init__(parent)
+ self.post_data = post_data_dict
+ self.parent_dialog = parent_dialog_ref # Reference to FavoritePostsDialog
+
+ self.layout = QHBoxLayout(self)
+ self.layout.setContentsMargins(5, 3, 5, 3) # Reduced vertical margins slightly
+ self.layout.setSpacing(10)
+
+ self.checkbox = QCheckBox()
+ self.layout.addWidget(self.checkbox)
+
+ self.info_label = QLabel()
+ self.info_label.setWordWrap(True)
+ self.info_label.setTextFormat(Qt.RichText)
+ 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')
+
+ 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}"
+
+ line1_html = f"{html.escape(creator_display)} - {html.escape(post_title_text)}"
+ line2_html = html.escape(known_line_text)
+ line3_html = html.escape(details_line_text)
+
+ display_html = f"{line1_html}
{line2_html}
{line3_html}"
+ self.info_label.setText(display_html)
+
+ def isChecked(self): return self.checkbox.isChecked()
+ def setCheckState(self, state): self.checkbox.setCheckState(state)
+ def get_post_data(self): return self.post_data
+
+class FavoritePostsDialog(QDialog):
+ """Dialog to display and select favorite posts."""
+ def __init__(self, parent_app, cookies_config, known_names_list_ref):
+ super().__init__(parent_app)
+ self.parent_app = parent_app
+ self.cookies_config = cookies_config
+ self.all_fetched_posts = []
+ self.selected_posts_data = []
+ self.known_names_list_ref = known_names_list_ref # Store reference to global KNOWN_NAMES
+ self.fetcher_thread = None # For the worker thread
+
+ self.setWindowTitle("Favorite Posts")
+ self.setModal(True)
+ self.setMinimumSize(600, 600) # Slightly wider for post titles
+ if hasattr(self.parent_app, 'get_dark_theme'):
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ self._init_ui()
+ self._start_fetching_favorite_posts() # Changed to start thread
+
+ def _init_ui(self):
+ main_layout = QVBoxLayout(self)
+
+ self.status_label = QLabel("Loading favorite posts...")
+ self.status_label.setAlignment(Qt.AlignCenter)
+ main_layout.addWidget(self.status_label)
+
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setTextVisible(False) # Or True if you want text on the bar
+ self.progress_bar.setVisible(False) # Initially hidden
+ main_layout.addWidget(self.progress_bar)
+
+ self.search_input = QLineEdit()
+ self.search_input.setPlaceholderText("Search posts (title, creator ID, service)...")
+ self.search_input.textChanged.connect(self._filter_post_list_display)
+ main_layout.addWidget(self.search_input)
+
+ self.post_list_widget = QListWidget()
+ self.post_list_widget.setStyleSheet("""
+ QListWidget::item {
+ border-bottom: 1px solid #4A4A4A;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ }""")
+ main_layout.addWidget(self.post_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):
+ if hasattr(self.parent_app, 'log_signal') and self.parent_app.log_signal:
+ self.parent_app.log_signal.emit(f"[FavPostsDialog] {message}")
+ else:
+ print(f"[FavPostsDialog] {message}")
+
+ def _start_fetching_favorite_posts(self):
+ self.download_button.setEnabled(False) # Disable download button during fetch
+ self.status_label.setText("Initializing favorite posts fetch...")
+
+ 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)
+ self.fetcher_thread.status_update.connect(self.status_label.setText)
+ self.fetcher_thread.finished.connect(self._on_fetch_completed)
+ self.progress_bar.setVisible(True)
+ self.fetcher_thread.start()
+
+ def _set_progress_bar_value(self, value, maximum):
+ if maximum == 0: # Indeterminate
+ self.progress_bar.setRange(0, 0)
+ self.progress_bar.setValue(0) # Some styles might need value set for indeterminate
+ else:
+ self.progress_bar.setRange(0, maximum)
+ self.progress_bar.setValue(value)
+
+ def _on_fetch_completed(self, fetched_posts_list, error_msg):
+ if error_msg:
+ self.status_label.setText(error_msg)
+ self._logger(error_msg) # Log to main app log
+ QMessageBox.critical(self, "Fetch Error", error_msg)
+ # Keep download button disabled or handle as appropriate
+ return
+
+ self.progress_bar.setVisible(False)
+ self.all_fetched_posts = fetched_posts_list
+ self._populate_post_list_widget()
+ 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:
+ 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")]
+
+ for alias in aliases_to_check:
+ if not alias:
+ continue
+ pattern = r"(?i)\b" + re.escape(alias) + r"\b"
+ if re.search(pattern, post_title):
+ return known_entry.get("name")
+ return None
+
+ 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
+
+ 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
+
+ def _filter_post_list_display(self):
+ search_text = self.search_input.text().lower().strip()
+ if not search_text:
+ self._populate_post_list_widget()
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+ self.selected_posts_data.append(post_data_for_download)
+
+ if not self.selected_posts_data:
+ QMessageBox.information(self, "No Selection", "Please select at least one post to download.")
+ return
+ self.accept()
+
+ 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):
@@ -1264,6 +1627,8 @@ class DownloaderApp(QWidget):
self.add_to_filter_button.clicked.connect(self._show_add_to_filter_dialog)
if hasattr(self, 'favorite_mode_artists_button'):
self.favorite_mode_artists_button.clicked.connect(self._show_favorite_artists_dialog)
+ if hasattr(self, 'favorite_mode_posts_button'): # New connection
+ 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)
@@ -1538,7 +1903,7 @@ class DownloaderApp(QWidget):
self.favorite_mode_artists_button = QPushButton("đŧī¸ Favorite Artists")
self.favorite_mode_artists_button.setToolTip("Browse and manage favorite artists (functionality TBD).")
self.favorite_mode_artists_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- self.favorite_mode_posts_button = QPushButton("đ Favorite Posts")
+ self.favorite_mode_posts_button = QPushButton("đ Favorite Posts") # Ensure this button is created
self.favorite_mode_posts_button.setToolTip("Browse and manage favorite posts (functionality TBD).")
self.favorite_mode_posts_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
@@ -3166,14 +3531,15 @@ class DownloaderApp(QWidget):
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER
if self._is_download_active():
- QMessageBox.warning(self, "Busy", "A download is already running."); return
+ QMessageBox.warning(self, "Busy", "A download is already running.")
+ return False # Indicate failure to start
if self.favorite_mode_checkbox and self.favorite_mode_checkbox.isChecked() and not direct_api_url:
QMessageBox.information(self, "Favorite Mode Active",
"Favorite Mode is active. Please use the 'Favorite Artists' or 'Favorite Posts' buttons to start downloads in this mode, or uncheck 'Favorite Mode' to use the URL input.")
self.set_ui_enabled(True)
return
-
+ return False # Indicate failure to start
api_url = direct_api_url if direct_api_url else self.link_input.text().strip()
main_ui_download_dir = self.dir_input.text().strip() # Always get the main dir from UI
@@ -3188,8 +3554,8 @@ 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)
- return
+ # self.set_ui_enabled(True) # Removed
+ return False # Indicate failure to start
if use_multithreading_enabled_by_checkbox:
if num_threads_from_gui > MAX_THREADS:
@@ -3227,8 +3593,8 @@ class DownloaderApp(QWidget):
if soft_warning_msg_box.clickedButton() == change_button:
self.log_signal.emit(f"âšī¸ User opted to change thread count from {num_threads_from_gui} after advisory.")
self.thread_count_input.setFocus()
- self.thread_count_input.selectAll()
- return # Exit start_download to allow user to change value
+ self.thread_count_input.selectAll() # type: ignore
+ return False # Indicate failure to start, user needs to adjust
raw_skip_words = self.skip_words_input.text().strip()
skip_words_list = [word.strip().lower() for word in raw_skip_words.split(',') if word.strip()]
@@ -3265,33 +3631,34 @@ class DownloaderApp(QWidget):
effective_skip_zip = self.skip_zip_checkbox.isChecked() # Keep user's choice
effective_skip_rar = self.skip_rar_checkbox.isChecked() # Keep user's choice
- if not api_url: QMessageBox.critical(self, "Input Error", "URL is required."); return
+ if not api_url:
+ QMessageBox.critical(self, "Input Error", "URL is required.")
+ return False # Indicate failure to start
+
if override_output_dir:
if not main_ui_download_dir:
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)
+ # 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.")
- self.download_finished(0, 0, True, []) # Simulate cancellation for this item
- return
+ return False # Indicate failure to start
if not os.path.isdir(main_ui_download_dir):
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)
+ # 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.")
- self.download_finished(0, 0, True, [])
- return
+ return False # Indicate failure to start
effective_output_dir_for_run = override_output_dir
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)
- return
+ # 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):
reply = QMessageBox.question(self, "Create Directory?",
@@ -3303,17 +3670,18 @@ 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)
- return
+ # 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)
- return
+ # self.set_ui_enabled(True) # Removed
+ return False # Indicate failure to start
effective_output_dir_for_run = main_ui_download_dir
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
- QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format."); return
+ QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
+ return False # Indicate failure to start
if compress_images and Image is None:
@@ -3370,10 +3738,12 @@ 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); return # Re-enable UI and stop
+ # 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); return # Re-enable UI and stop
+ # 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
raw_character_filters_text = self.character_input.text().strip() # Get current text
@@ -3426,7 +3796,8 @@ 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); return
+ # 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
self.log_signal.emit(f"âšī¸ User chose to add {len(dialog_result)} new entry/entries to Known.txt.")
@@ -3469,7 +3840,8 @@ class DownloaderApp(QWidget):
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
msg_box.exec_()
if msg_box.clickedButton() == cancel_button:
- self.log_signal.emit("â Download cancelled due to Manga Mode filter warning."); return
+ self.log_signal.emit("â Download cancelled due to Manga Mode filter warning.")
+ return False # Indicate failure to start
else:
self.log_signal.emit("â ī¸ Proceeding with Manga Mode without a specific title filter.")
self.dynamic_character_filter_holder.set_filters(actual_filters_to_use_for_run)
@@ -3676,6 +4048,7 @@ class DownloaderApp(QWidget):
self.download_finished(0,0,False, [])
if self.pause_event: self.pause_event.clear()
self.is_paused = False # Ensure pause state is reset on error
+ return True # Indicate successful start
def start_single_threaded_download(self, **kwargs):
@@ -4056,7 +4429,7 @@ class DownloaderApp(QWidget):
if self.favorite_mode_artists_button:
self.favorite_mode_artists_button.setEnabled(enabled and is_fav_mode_active)
if self.favorite_mode_posts_button:
- self.favorite_mode_posts_button.setEnabled(enabled and is_fav_mode_active)
+ self.favorite_mode_posts_button.setEnabled(enabled and is_fav_mode_active) # Enable/disable this button
if self.download_btn:
self.download_btn.setEnabled(enabled and not is_fav_mode_active) # Only if UI enabled AND not in fav mode
@@ -4213,11 +4586,11 @@ class DownloaderApp(QWidget):
def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None):
if kept_original_names_list is None:
- kept_original_names_list = self.all_kept_original_filenames if hasattr(self, 'all_kept_original_filenames') else []
+ kept_original_names_list = list(self.all_kept_original_filenames) if hasattr(self, 'all_kept_original_filenames') else []
if kept_original_names_list is None:
kept_original_names_list = []
- status_message = "Cancelled by user" if cancelled_by_user else "Finished"
+ status_message = "Cancelled by user" if cancelled_by_user else "Completed"
if cancelled_by_user and self.retryable_failed_files_info:
self.log_signal.emit(f" Download cancelled, discarding {len(self.retryable_failed_files_info)} file(s) that were pending retry.")
self.retryable_failed_files_info.clear()
@@ -4292,7 +4665,7 @@ class DownloaderApp(QWidget):
if self.is_processing_favorites_queue:
if not self.favorite_download_queue: # No more items in the queue
self.is_processing_favorites_queue = False
- self.log_signal.emit("â
All queued favorite artist downloads have been initiated.")
+ self.log_signal.emit(f"â
All {self.current_processing_favorite_item_info.get('type', 'item')} downloads from favorite queue have been processed.")
self.set_ui_enabled(True) # All favorites done, reset UI to idle
else: # More items in the queue, start the next one
self._process_next_favorite_download()
@@ -5090,40 +5463,98 @@ class DownloaderApp(QWidget):
self.log_signal.emit(f"âšī¸ Queuing {len(selected_artists)} favorite artist(s) for download.")
for artist_data in selected_artists: # Iterate over list of dicts
- self.favorite_download_queue.append(artist_data) # Append the dict
+ 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
+ # 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.")
else:
self.log_signal.emit("âšī¸ Favorite artists selection cancelled.")
- def _process_next_favorite_download(self):
- if not self.is_processing_favorites_queue or not self.favorite_download_queue:
- if self.is_processing_favorites_queue and not self.favorite_download_queue:
- self.is_processing_favorites_queue = False
- self.log_signal.emit("â
All favorite artist downloads from queue have been initiated.")
+ def _show_favorite_posts_dialog(self):
+ if self._is_download_active() or self.is_processing_favorites_queue:
+ QMessageBox.warning(self, "Busy", "Another download operation is already in progress.")
return
+ cookies_config = {
+ 'use_cookie': self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False,
+ 'cookie_text': self.cookie_text_input.text() if hasattr(self, 'cookie_text_input') else "",
+ 'selected_cookie_file': self.selected_cookie_filepath,
+ 'app_base_dir': self.app_base_dir
+ }
+ global KNOWN_NAMES # Ensure we have access to the global
+
+ dialog = FavoritePostsDialog(self, cookies_config, KNOWN_NAMES) # Pass KNOWN_NAMES
+ if dialog.exec_() == QDialog.Accepted:
+ selected_posts = dialog.get_selected_posts()
+ 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:////user//post/
+ # 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']}"
+
+ queue_item = {
+ 'url': direct_post_url,
+ 'name': post_data['title'], # For logging purposes
+ 'name_for_folder': post_data['creator_id'], # Use creator_id for folder name
+ 'type': 'post'
+ }
+ 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.")
+ else:
+ 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
- self.current_processing_favorite_artist_info = self.favorite_download_queue.popleft() # Store the dict
- next_url = self.current_processing_favorite_artist_info['url']
- artist_name_for_log = self.current_processing_favorite_artist_info.get('name', 'Unknown Artist')
+ # 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
+ self.log_signal.emit("â
All favorite items from queue have been processed.")
+ self.set_ui_enabled(True) # Re-enable UI fully
+ return
- self.log_signal.emit(f"âļī¸ Processing next favorite from queue: '{artist_name_for_log}' ({next_url})")
+ # 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()
+ next_url = self.current_processing_favorite_item_info['url']
+ item_display_name = self.current_processing_favorite_item_info.get('name', 'Unknown Item') # This is artist name or post title
+
+ self.log_signal.emit(f"âļī¸ Processing next favorite from queue: '{item_display_name}' ({next_url})")
override_dir = None
if self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS and self.dir_input.text().strip(): # Ensure main dir is set
main_download_dir = self.dir_input.text().strip()
- artist_folder_name = clean_folder_name(artist_name_for_log)
- override_dir = os.path.join(main_download_dir, artist_folder_name)
+ folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
+ item_specific_folder_name = clean_folder_name(folder_name_key) # artist_name or creator_id
+ override_dir = os.path.join(main_download_dir, item_specific_folder_name)
self.log_signal.emit(f" Favorite Scope: Artist Folders. Target directory: '{override_dir}'")
- self.start_download(direct_api_url=next_url, override_output_dir=override_dir) # Pass direct_api_url and override_dir
+
+ 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.
+ self.download_finished(total_downloaded=0, total_skipped=1, cancelled_by_user=True, kept_original_names_list=[])
if __name__ == '__main__':
import traceback
diff --git a/multipart_downloader.py b/multipart_downloader.py
index 465462f..d5161da 100644
--- a/multipart_downloader.py
+++ b/multipart_downloader.py
@@ -122,14 +122,20 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte,
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
logger_func(f" â [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
+ if isinstance(e, requests.exceptions.ConnectionError) and \
+ ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
+ logger_func(" đĄ This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
if attempt == MAX_CHUNK_DOWNLOAD_RETRIES:
logger_func(f" â [Chunk {part_num + 1}/{total_parts}] Failed after {MAX_CHUNK_DOWNLOAD_RETRIES} retries.")
return bytes_this_chunk, False
except requests.exceptions.RequestException as e: # Includes 4xx/5xx errors after raise_for_status
logger_func(f" â [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
+ if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)): # More general check
+ logger_func(" đĄ This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
return bytes_this_chunk, False
except Exception as e:
logger_func(f" â [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
+
return bytes_this_chunk, False
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False