This commit is contained in:
Yuvi9587
2025-05-28 20:33:06 +05:30
parent be3a522305
commit 8137c76eb4
3 changed files with 542 additions and 56 deletions

View File

@@ -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)}")

519
main.py
View File

@@ -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"<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)
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://<domain>/<service>/user/<creator_id>/post/<post_id>
# For now, assume kemono.su. TODO: Handle coomer.su if applicable
domain = "kemono.su" # Or determine from service/parent app settings
direct_post_url = f"https://{domain}/{post_data['service']}/user/{post_data['creator_id']}/post/{post_data['post_id']}"
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

View File

@@ -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