mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-17 15:36:51 +00:00
Commit
This commit is contained in:
parent
257111d462
commit
4a93b721e2
@ -159,8 +159,6 @@ def download_from_api(
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Download_from_api cancelled at start.")
|
||||
return
|
||||
|
||||
# The code that defined api_domain was moved from here to the top of the function
|
||||
|
||||
if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
|
||||
@ -312,6 +310,8 @@ def download_from_api(
|
||||
current_offset = (start_page - 1) * page_size
|
||||
current_page_num = start_page
|
||||
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
|
||||
|
||||
# --- START OF MODIFIED BLOCK ---
|
||||
while True:
|
||||
if pause_event and pause_event.is_set():
|
||||
logger(" Post fetching loop paused...")
|
||||
@ -321,18 +321,23 @@ def download_from_api(
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
|
||||
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Post fetching loop cancelled.")
|
||||
break
|
||||
|
||||
if target_post_id and processed_target_post_flag:
|
||||
break
|
||||
|
||||
if not target_post_id and end_page and current_page_num > end_page:
|
||||
logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
|
||||
break
|
||||
|
||||
try:
|
||||
posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
if not isinstance(posts_batch, list):
|
||||
logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||
# 1. Fetch the raw batch of posts
|
||||
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
if not isinstance(raw_posts_batch, list):
|
||||
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||
break
|
||||
except RuntimeError as e:
|
||||
if "cancelled by user" in str(e).lower():
|
||||
@ -344,14 +349,9 @@ def download_from_api(
|
||||
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
if processed_post_ids:
|
||||
original_count = len(posts_batch)
|
||||
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
|
||||
if not posts_batch:
|
||||
|
||||
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
|
||||
if not raw_posts_batch:
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
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:
|
||||
@ -359,20 +359,45 @@ def download_from_api(
|
||||
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
|
||||
else:
|
||||
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
|
||||
break
|
||||
break # This break is now correct.
|
||||
|
||||
# 3. Filter the batch against processed IDs
|
||||
posts_batch_to_yield = raw_posts_batch
|
||||
original_count = len(raw_posts_batch)
|
||||
|
||||
if processed_post_ids:
|
||||
posts_batch_to_yield = [post for post in raw_posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch_to_yield)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
|
||||
# 4. Process the *filtered* batch
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
|
||||
# Still searching for a specific post
|
||||
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
|
||||
if matching_post:
|
||||
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
|
||||
yield [matching_post]
|
||||
processed_target_post_flag = True
|
||||
elif not target_post_id:
|
||||
yield posts_batch
|
||||
# Downloading a creator feed
|
||||
if posts_batch_to_yield:
|
||||
# We found new posts on this page, yield them
|
||||
yield posts_batch_to_yield
|
||||
elif original_count > 0:
|
||||
# We found 0 new posts, but the page *did* have posts (they were just skipped).
|
||||
# Log this and continue to the next page.
|
||||
logger(f" No new posts found on page {current_page_num}. Checking next page...")
|
||||
# If original_count was 0, the `if not raw_posts_batch:` check
|
||||
# already caught it and broke the loop.
|
||||
|
||||
if processed_target_post_flag:
|
||||
break
|
||||
|
||||
current_offset += page_size
|
||||
current_page_num += 1
|
||||
time.sleep(0.6)
|
||||
# --- END OF MODIFIED BLOCK ---
|
||||
|
||||
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
|
||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ from ..main_window import get_app_icon_object
|
||||
from ...core.api_client import download_from_api
|
||||
from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ...utils.resolution import get_dark_theme
|
||||
# --- IMPORT THE NEW DIALOG ---
|
||||
from .UpdateCheckDialog import UpdateCheckDialog
|
||||
|
||||
|
||||
class PostsFetcherThread (QThread ):
|
||||
@ -151,8 +153,13 @@ class EmptyPopupDialog (QDialog ):
|
||||
app_icon =get_app_icon_object ()
|
||||
if app_icon and not app_icon .isNull ():
|
||||
self .setWindowIcon (app_icon )
|
||||
|
||||
# --- MODIFIED: Store a list of profiles now ---
|
||||
self.update_profiles_list = None
|
||||
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
|
||||
self.update_profile_data = None
|
||||
self.update_creator_name = None
|
||||
|
||||
self .selected_creators_for_queue =[]
|
||||
self .globally_selected_creators ={}
|
||||
self .fetched_posts_data ={}
|
||||
@ -321,29 +328,34 @@ class EmptyPopupDialog (QDialog ):
|
||||
pass
|
||||
|
||||
def _handle_update_check(self):
|
||||
"""Opens a dialog to select a creator profile and loads it for an update session."""
|
||||
appdata_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
"""
|
||||
--- MODIFIED FUNCTION ---
|
||||
Opens the new UpdateCheckDialog instead of a QFileDialog.
|
||||
If a profile is selected, it sets the dialog's result properties
|
||||
and accepts the dialog, just like the old file dialog logic did.
|
||||
"""
|
||||
# --- NEW BEHAVIOR ---
|
||||
# Pass the app_base_dir and a reference to the main app (for translations/theme)
|
||||
dialog = UpdateCheckDialog(self.app_base_dir, self.parent_app, self)
|
||||
|
||||
if not os.path.isdir(profiles_dir):
|
||||
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
|
||||
return
|
||||
|
||||
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if 'creator_url' not in data or 'processed_post_ids' not in data:
|
||||
raise ValueError("Invalid profile format.")
|
||||
|
||||
self.update_profile_data = data
|
||||
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
|
||||
self.accept() # Close the dialog and signal success
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
# --- MODIFIED: Get a list of profiles now ---
|
||||
selected_profiles = dialog.get_selected_profiles()
|
||||
if selected_profiles:
|
||||
try:
|
||||
# --- MODIFIED: Store the list ---
|
||||
self.update_profiles_list = selected_profiles
|
||||
|
||||
# --- Set deprecated single-profile fields for backward compatibility (optional) ---
|
||||
# --- This helps if other parts of the main window still expect one profile ---
|
||||
self.update_profile_data = selected_profiles[0]['data']
|
||||
self.update_creator_name = selected_profiles[0]['name']
|
||||
|
||||
self.accept() # Close EmptyPopupDialog and signal success to main_window
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error Loading Profile",
|
||||
f"Could not process the selected profile data:\n\n{e}")
|
||||
# --- END OF NEW BEHAVIOR ---
|
||||
|
||||
def _handle_fetch_posts_click (self ):
|
||||
selected_creators =list (self .globally_selected_creators .values ())
|
||||
@ -981,9 +993,14 @@ class EmptyPopupDialog (QDialog ):
|
||||
def _handle_posts_close_view (self ):
|
||||
self .right_pane_widget .hide ()
|
||||
self .main_splitter .setSizes ([self .width (),0 ])
|
||||
self .posts_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
|
||||
|
||||
# --- MODIFIED: Added check before disconnect ---
|
||||
if hasattr (self ,'_handle_post_item_check_changed'):
|
||||
self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
|
||||
try:
|
||||
self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
|
||||
except TypeError:
|
||||
pass # Already disconnected
|
||||
|
||||
self .posts_search_input .setVisible (False )
|
||||
self .posts_search_input .clear ()
|
||||
self .globally_selected_post_ids .clear ()
|
||||
|
||||
179
src/ui/dialogs/UpdateCheckDialog.py
Normal file
179
src/ui/dialogs/UpdateCheckDialog.py
Normal file
@ -0,0 +1,179 @@
|
||||
# --- Standard Library Imports ---
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class UpdateCheckDialog(QDialog):
|
||||
"""
|
||||
A dialog that lists all creator .json profiles with checkboxes
|
||||
and allows the user to select multiple to check for updates.
|
||||
"""
|
||||
|
||||
def __init__(self, app_base_dir, parent_app_ref, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent_app_ref
|
||||
self.app_base_dir = app_base_dir
|
||||
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
|
||||
|
||||
self._init_ui()
|
||||
self._load_profiles()
|
||||
self._retranslate_ui()
|
||||
|
||||
# Apply theme from parent
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initializes the UI components."""
|
||||
self.setWindowTitle("Check for Updates")
|
||||
self.setMinimumSize(400, 450)
|
||||
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.info_label = QLabel("Select creator profiles to check for updates:")
|
||||
layout.addWidget(self.info_label)
|
||||
|
||||
# --- List Widget with Checkboxes ---
|
||||
self.list_widget = QListWidget()
|
||||
# No selection mode, we only care about checkboxes
|
||||
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
# --- All Buttons in One Horizontal Layout ---
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6) # small even spacing between all buttons
|
||||
|
||||
self.select_all_button = QPushButton("Select All")
|
||||
self.select_all_button.clicked.connect(self._toggle_all_checkboxes)
|
||||
|
||||
self.deselect_all_button = QPushButton("Deselect All")
|
||||
self.deselect_all_button.clicked.connect(self._toggle_all_checkboxes)
|
||||
|
||||
self.close_button = QPushButton("Close")
|
||||
self.close_button.clicked.connect(self.reject)
|
||||
|
||||
self.check_button = QPushButton("Check Selected")
|
||||
self.check_button.clicked.connect(self.on_check_selected)
|
||||
self.check_button.setDefault(True)
|
||||
|
||||
# Add buttons without a stretch (so no large gap)
|
||||
button_layout.addWidget(self.select_all_button)
|
||||
button_layout.addWidget(self.deselect_all_button)
|
||||
button_layout.addWidget(self.close_button)
|
||||
button_layout.addWidget(self.check_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _retranslate_ui(self):
|
||||
"""Translates the UI elements."""
|
||||
self.setWindowTitle(self._tr("update_check_dialog_title", "Check for Updates"))
|
||||
self.info_label.setText(self._tr("update_check_dialog_info_multiple", "Select creator profiles to check for updates:"))
|
||||
self.select_all_button.setText(self._tr("select_all_button_text", "Select All"))
|
||||
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
|
||||
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
|
||||
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
|
||||
|
||||
def _load_profiles(self):
|
||||
"""Loads all .json files from the creator_profiles directory as checkable items."""
|
||||
appdata_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
|
||||
if not os.path.isdir(profiles_dir):
|
||||
QMessageBox.warning(self,
|
||||
self._tr("update_check_dir_not_found_title", "Directory Not Found"),
|
||||
self._tr("update_check_dir_not_found_msg",
|
||||
"The creator profiles directory does not exist yet.\n\nPath: {path}")
|
||||
.format(path=profiles_dir))
|
||||
return
|
||||
|
||||
profiles_found = []
|
||||
for filename in os.listdir(profiles_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(profiles_dir, filename)
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Basic validation to ensure it's a valid profile
|
||||
if 'creator_url' in data and 'processed_post_ids' in data:
|
||||
creator_name = os.path.splitext(filename)[0]
|
||||
profiles_found.append({'name': creator_name, 'data': data})
|
||||
else:
|
||||
print(f"Skipping invalid profile: {filename}")
|
||||
except Exception as e:
|
||||
print(f"Failed to load profile {filename}: {e}")
|
||||
|
||||
profiles_found.sort(key=lambda x: x['name'].lower())
|
||||
|
||||
for profile_info in profiles_found:
|
||||
item = QListWidgetItem(profile_info['name'])
|
||||
item.setData(Qt.UserRole, profile_info)
|
||||
# --- Make item checkable ---
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
if not profiles_found:
|
||||
self.list_widget.addItem(self._tr("update_check_no_profiles", "No creator profiles found."))
|
||||
self.list_widget.setEnabled(False)
|
||||
self.check_button.setEnabled(False)
|
||||
self.select_all_button.setEnabled(False)
|
||||
self.deselect_all_button.setEnabled(False)
|
||||
|
||||
def _toggle_all_checkboxes(self):
|
||||
"""Handles Select All and Deselect All button clicks."""
|
||||
sender = self.sender()
|
||||
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
|
||||
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setCheckState(check_state)
|
||||
|
||||
def on_check_selected(self):
|
||||
"""Handles the 'Check Selected' button click."""
|
||||
self.selected_profiles_list = []
|
||||
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item.checkState() == Qt.Checked:
|
||||
profile_info = item.data(Qt.UserRole)
|
||||
if profile_info:
|
||||
self.selected_profiles_list.append(profile_info)
|
||||
|
||||
if not self.selected_profiles_list:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("update_check_no_selection_title", "No Selection"),
|
||||
self._tr("update_check_no_selection_msg", "Please select at least one creator to check."))
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_selected_profiles(self):
|
||||
"""Returns the list of profile data selected by the user."""
|
||||
return self.selected_profiles_list
|
||||
@ -149,6 +149,7 @@ class DownloaderApp (QWidget ):
|
||||
external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
|
||||
file_progress_signal =pyqtSignal (str ,object )
|
||||
fetch_only_complete_signal = pyqtSignal(list)
|
||||
batch_update_check_complete_signal = pyqtSignal(list)
|
||||
|
||||
|
||||
def __init__(self):
|
||||
@ -156,6 +157,10 @@ class DownloaderApp (QWidget ):
|
||||
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
|
||||
self.active_update_profile = None
|
||||
self.new_posts_for_update = []
|
||||
|
||||
self.active_update_profiles_list = [] # For batch updates
|
||||
self.fetched_posts_for_batch_update = [] # Stores {'post_data': ..., 'creator_settings': ...}
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.is_finishing = False
|
||||
self.finish_lock = threading.Lock()
|
||||
|
||||
@ -334,7 +339,7 @@ class DownloaderApp (QWidget ):
|
||||
self.download_location_label_widget = None
|
||||
self.remove_from_filename_label_widget = None
|
||||
self.skip_words_label_widget = None
|
||||
self.setWindowTitle("Kemono Downloader v7.5.2")
|
||||
self.setWindowTitle("Kemono Downloader v7.6.0")
|
||||
setup_ui(self)
|
||||
self._connect_signals()
|
||||
if hasattr(self, 'character_input'):
|
||||
@ -775,6 +780,17 @@ class DownloaderApp (QWidget ):
|
||||
self.cancel_btn.clicked.connect(self.reset_application_state)
|
||||
return # <-- This 'return' is CRITICAL
|
||||
|
||||
elif self.is_ready_to_download_batch_update:
|
||||
num_posts = len(self.fetched_posts_for_batch_update)
|
||||
self.download_btn.setText(f"⬇️ Start Download ({num_posts} New Posts)")
|
||||
self.download_btn.setEnabled(True)
|
||||
self.download_btn.clicked.connect(self.start_download)
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setText("🗑️ Clear Update")
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.clicked.connect(self.reset_application_state)
|
||||
return
|
||||
|
||||
if self.active_update_profile and self.new_posts_for_update and not is_download_active:
|
||||
# State: Update confirmation (new posts found, waiting for user to start)
|
||||
num_new = len(self.new_posts_for_update)
|
||||
@ -1130,6 +1146,7 @@ class DownloaderApp (QWidget ):
|
||||
self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
|
||||
self.actual_gui_signals.worker_finished_signal.connect(self._handle_worker_result)
|
||||
self .actual_gui_signals .file_download_status_signal .connect (lambda status :None )
|
||||
self.batch_update_check_complete_signal.connect(self._batch_update_check_finished)
|
||||
self.fetch_only_complete_signal.connect(self._fetch_only_finished)
|
||||
|
||||
if hasattr (self ,'character_input'):
|
||||
@ -1795,7 +1812,9 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
supported_platforms_for_button ={'mega','google drive','dropbox'}
|
||||
has_supported_links =any (
|
||||
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
|
||||
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
|
||||
for link_info in self.extracted_links_cache
|
||||
|
||||
)
|
||||
self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links )
|
||||
|
||||
@ -3515,6 +3534,18 @@ class DownloaderApp (QWidget ):
|
||||
return get_theme_stylesheet(actual_scale)
|
||||
|
||||
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
|
||||
|
||||
if not is_restore and not is_continuation:
|
||||
if self.main_log_output: self.main_log_output.clear()
|
||||
if self.external_log_output: self.external_log_output.clear()
|
||||
if self.missed_character_log_output: self.missed_character_log_output.clear()
|
||||
self.missed_key_terms_buffer.clear()
|
||||
self.already_logged_bold_key_terms.clear()
|
||||
|
||||
if self.is_ready_to_download_batch_update:
|
||||
self._start_download_of_batch_update()
|
||||
return True
|
||||
|
||||
if not direct_api_url:
|
||||
api_url_text = self.link_input.text().strip().lower()
|
||||
batch_handlers = {
|
||||
@ -4411,6 +4442,7 @@ class DownloaderApp (QWidget ):
|
||||
if self.pause_event: self.pause_event.clear()
|
||||
self.is_paused = False
|
||||
return True
|
||||
|
||||
def restore_download(self):
|
||||
"""Initiates the download restoration process."""
|
||||
if self._is_download_active():
|
||||
@ -4574,6 +4606,294 @@ class DownloaderApp (QWidget ):
|
||||
self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
|
||||
self._update_error_button_count()
|
||||
|
||||
def _start_batch_update_check(self, profiles_list):
|
||||
"""Launches a background thread to check multiple profiles for updates."""
|
||||
self.set_ui_enabled(False)
|
||||
self.progress_label.setText(self._tr("batch_update_checking", "Checking for updates..."))
|
||||
self.cancellation_event.clear()
|
||||
|
||||
# Start the background thread
|
||||
self.download_thread = threading.Thread(
|
||||
target=self._run_batch_update_check_thread,
|
||||
args=(profiles_list,),
|
||||
daemon=True
|
||||
)
|
||||
self.download_thread.start()
|
||||
self._update_button_states_and_connections()
|
||||
|
||||
def _run_batch_update_check_thread(self, profiles_list):
|
||||
"""
|
||||
(BACKGROUND THREAD)
|
||||
Iterates profiles, calls download_from_api for each, and collects new posts.
|
||||
"""
|
||||
master_new_post_list = []
|
||||
total_profiles = len(profiles_list)
|
||||
|
||||
for i, profile in enumerate(profiles_list):
|
||||
if self.cancellation_event.is_set():
|
||||
break
|
||||
|
||||
profile_name = profile.get('name', 'Unknown')
|
||||
self.log_signal.emit(f"Checking {profile_name} ({i+1}/{total_profiles})...")
|
||||
|
||||
try:
|
||||
profile_data = profile.get('data', {})
|
||||
url = profile_data.get('creator_url', [])[0] # Get first URL
|
||||
processed_ids = set(profile_data.get('processed_post_ids', []))
|
||||
creator_settings = profile_data.get('settings', {})
|
||||
|
||||
# Use common cookie settings from the UI
|
||||
use_cookie = self.use_cookie_checkbox.isChecked()
|
||||
cookie_text = self.cookie_text_input.text()
|
||||
cookie_file = self.selected_cookie_filepath
|
||||
|
||||
post_generator = download_from_api(
|
||||
api_url_input=url,
|
||||
logger=lambda msg: None, # Suppress logs
|
||||
cancellation_event=self.cancellation_event,
|
||||
pause_event=self.pause_event,
|
||||
use_cookie=use_cookie,
|
||||
cookie_text=cookie_text,
|
||||
selected_cookie_file=cookie_file,
|
||||
app_base_dir=self.app_base_dir,
|
||||
processed_post_ids=processed_ids,
|
||||
end_page=5
|
||||
)
|
||||
|
||||
for post_batch in post_generator:
|
||||
if self.cancellation_event.is_set(): break
|
||||
for post_data in post_batch:
|
||||
# Store the post AND the ENTIRE profile data
|
||||
master_new_post_list.append({
|
||||
'post_data': post_data,
|
||||
'profile_data': profile_data, # Pass the full profile
|
||||
'creator_name': profile_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.log_signal.emit(f"❌ Error checking {profile_name}: {e}")
|
||||
|
||||
# Emit the final aggregated list
|
||||
self.batch_update_check_complete_signal.emit(master_new_post_list)
|
||||
|
||||
def _batch_update_check_finished(self, all_new_posts_list):
|
||||
"""
|
||||
(GUI THREAD)
|
||||
Called when the batch update check is complete. Updates UI.
|
||||
"""
|
||||
self.download_thread = None # Clear the thread
|
||||
|
||||
if self.cancellation_event.is_set():
|
||||
self.log_signal.emit("ℹ️ Update check was cancelled.")
|
||||
self.reset_application_state() # Full reset
|
||||
return
|
||||
|
||||
if not all_new_posts_list:
|
||||
self.log_signal.emit("✅ All selected creators are up to date! No new posts found.")
|
||||
QMessageBox.information(self, "Up to Date", "No new posts were found for the selected creators.")
|
||||
self.reset_application_state() # Full reset
|
||||
return
|
||||
|
||||
total_posts = len(all_new_posts_list)
|
||||
|
||||
# --- MODIFIED BLOCK ---
|
||||
# Get the set of unique creator names who have new posts
|
||||
creators_with_new_posts = sorted(list(set(p['creator_name'] for p in all_new_posts_list)))
|
||||
total_creators = len(creators_with_new_posts)
|
||||
|
||||
self.log_signal.emit("=" * 40)
|
||||
|
||||
# Add the new line you requested
|
||||
if creators_with_new_posts:
|
||||
self.log_signal.emit(f"Creators With New Posts - {', '.join(creators_with_new_posts)}")
|
||||
|
||||
# Log the original summary line
|
||||
self.log_signal.emit(f"✅ Update check complete. Found {total_posts} new post(s) across {total_creators} creator(s).")
|
||||
# --- END OF MODIFIED BLOCK ---
|
||||
|
||||
self.log_signal.emit(" Click 'Start Download' to begin.")
|
||||
|
||||
self.fetched_posts_for_batch_update = all_new_posts_list
|
||||
self.is_ready_to_download_batch_update = True
|
||||
|
||||
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
|
||||
self.set_ui_enabled(True) # Re-enable UI
|
||||
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
|
||||
|
||||
def _start_download_of_batch_update(self):
|
||||
"""
|
||||
(GUI THREAD)
|
||||
Initiates the download of the posts found during the batch update check.
|
||||
|
||||
--- THIS IS THE CORRECTED ROBUST VERSION ---
|
||||
"""
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.log_signal.emit("=" * 40)
|
||||
self.log_signal.emit(f"🚀 Starting batch download for {len(self.fetched_posts_for_batch_update)} new post(s)...")
|
||||
|
||||
if self.main_log_output: self.main_log_output.clear()
|
||||
if self.external_log_output: self.external_log_output.clear()
|
||||
if self.missed_character_log_output: self.missed_character_log_output.clear()
|
||||
self.missed_key_terms_buffer.clear()
|
||||
self.already_logged_bold_key_terms.clear()
|
||||
|
||||
self.set_ui_enabled(False)
|
||||
|
||||
num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='PostWorker_')
|
||||
|
||||
self.total_posts_to_process = len(self.fetched_posts_for_batch_update)
|
||||
self.processed_posts_count = 0
|
||||
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
|
||||
|
||||
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
|
||||
|
||||
# 1. Define all LIVE RUNTIME arguments.
|
||||
# These are taken from the current app state and are the same for all workers.
|
||||
live_runtime_args = {
|
||||
'emitter': self.worker_to_gui_queue,
|
||||
'creator_name_cache': self.creator_name_cache,
|
||||
'known_names': list(KNOWN_NAMES),
|
||||
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
|
||||
'pause_event': self.pause_event,
|
||||
'cancellation_event': self.cancellation_event,
|
||||
'downloaded_files': self.downloaded_files,
|
||||
'downloaded_files_lock': self.downloaded_files_lock,
|
||||
'downloaded_file_hashes': self.downloaded_file_hashes,
|
||||
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
|
||||
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
|
||||
'num_file_threads': 1, # File threads per post worker
|
||||
'manga_date_file_counter_ref': None,
|
||||
'manga_global_file_counter_ref': None,
|
||||
'creator_download_folder_ignore_words': CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS,
|
||||
'downloaded_hash_counts': self.downloaded_hash_counts,
|
||||
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
|
||||
'skip_current_file_flag': None,
|
||||
'session_file_path': self.session_file_path,
|
||||
'session_lock': self.session_lock,
|
||||
'project_root_dir': self.app_base_dir,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
'start_offset': 0,
|
||||
'fetch_first': False,
|
||||
# Add live cookie settings
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'cookie_text': self.cookie_text_input.text(),
|
||||
'selected_cookie_file': self.selected_cookie_filepath,
|
||||
}
|
||||
|
||||
# 2. Define DEFAULTS for all settings that *should* be in the profile.
|
||||
# These will be used if the profile is old and missing a key.
|
||||
default_profile_settings = {
|
||||
'output_dir': self.dir_input.text().strip(), # Fallback to live UI
|
||||
'api_url': '',
|
||||
'character_filter_text': '',
|
||||
'skip_words_text': '',
|
||||
'remove_words_text': '',
|
||||
'custom_folder_name': None,
|
||||
'filter_mode': 'all',
|
||||
'text_only_scope': None,
|
||||
'text_export_format': 'txt',
|
||||
'single_pdf_mode': False,
|
||||
'skip_zip': False,
|
||||
'use_subfolders': False,
|
||||
'use_post_subfolders': False,
|
||||
'compress_images': False,
|
||||
'download_thumbnails': False,
|
||||
'skip_words_scope': SKIP_SCOPE_FILES,
|
||||
'char_filter_scope': CHAR_SCOPE_FILES,
|
||||
'show_external_links': False,
|
||||
'extract_links_only': False,
|
||||
'manga_mode_active': False,
|
||||
'manga_filename_style': STYLE_POST_TITLE,
|
||||
'allow_multipart_download': False,
|
||||
'manga_date_prefix': '',
|
||||
'scan_content_for_images': False,
|
||||
'use_date_prefix_for_subfolder': False,
|
||||
'date_prefix_format': "YYYY-MM-DD {post}",
|
||||
'keep_in_post_duplicates': False,
|
||||
'keep_duplicates_mode': DUPLICATE_HANDLING_HASH,
|
||||
'keep_duplicates_limit': 0,
|
||||
'multipart_scope': 'both',
|
||||
'multipart_parts_count': 4,
|
||||
'multipart_min_size_mb': 100,
|
||||
'manga_custom_filename_format': "{published} {title}",
|
||||
'manga_custom_date_format': "YYYY-MM-DD",
|
||||
'target_post_id_from_initial_url': None,
|
||||
'override_output_dir': None,
|
||||
'processed_post_ids': [],
|
||||
}
|
||||
|
||||
for item in self.fetched_posts_for_batch_update:
|
||||
post_data = item['post_data']
|
||||
|
||||
# --- THIS IS THE NEW, CORRECTED LOGIC ---
|
||||
full_profile_data = item.get('profile_data', {})
|
||||
saved_settings = full_profile_data.get('settings', {})
|
||||
# --- END OF NEW LOGIC ---
|
||||
|
||||
# 3. Construct the final arguments for this specific worker
|
||||
|
||||
# Start with a full set of defaults
|
||||
args_for_this_worker = default_profile_settings.copy()
|
||||
# Overwrite with any settings saved in the profile
|
||||
# This is where {"filter_mode": "video"} from Maplestar.json is applied
|
||||
args_for_this_worker.update(saved_settings)
|
||||
# Add all the live runtime arguments
|
||||
args_for_this_worker.update(live_runtime_args)
|
||||
|
||||
# 4. Manually parse values from the constructed args
|
||||
|
||||
# Set post-specific data
|
||||
args_for_this_worker['service'] = post_data.get('service')
|
||||
args_for_this_worker['user_id'] = post_data.get('user')
|
||||
|
||||
# Set download_root (which worker expects) from output_dir
|
||||
args_for_this_worker['download_root'] = args_for_this_worker.get('output_dir')
|
||||
|
||||
# Parse filters and commands
|
||||
raw_filters = args_for_this_worker.get('character_filter_text', '')
|
||||
parsed_filters, commands = self._parse_character_filters(raw_filters)
|
||||
args_for_this_worker['filter_character_list'] = parsed_filters
|
||||
args_for_this_worker['domain_override'] = commands.get('domain_override')
|
||||
args_for_this_worker['archive_only_mode'] = commands.get('archive_only', False)
|
||||
args_for_this_worker['sfp_threshold'] = commands.get('sfp_threshold')
|
||||
args_for_this_worker['handle_unknown_mode'] = commands.get('handle_unknown', False)
|
||||
|
||||
# Parse skip words and skip size
|
||||
skip_words_parts = [part.strip() for part in args_for_this_worker.get('skip_words_text', '').split(',') if part.strip()]
|
||||
args_for_this_worker['skip_file_size_mb'] = None
|
||||
args_for_this_worker['skip_words_list'] = []
|
||||
size_pattern = re.compile(r'\[(\d+)\]')
|
||||
for part in skip_words_parts:
|
||||
match = size_pattern.fullmatch(part)
|
||||
if match:
|
||||
args_for_this_worker['skip_file_size_mb'] = int(match.group(1))
|
||||
else:
|
||||
args_for_this_worker['skip_words_list'].append(part.lower())
|
||||
|
||||
# Parse remove_from_filename_words_list
|
||||
raw_remove_words = args_for_this_worker.get('remove_words_text', '')
|
||||
args_for_this_worker['remove_from_filename_words_list'] = [word.strip() for word in raw_remove_words.split(',') if word.strip()]
|
||||
|
||||
# Ensure processed_post_ids is a list (from the *original* profile data)
|
||||
args_for_this_worker['processed_post_ids'] = list(full_profile_data.get('processed_post_ids', []))
|
||||
|
||||
# Ensure api_url_input is set
|
||||
args_for_this_worker['api_url_input'] = args_for_this_worker.get('api_url', '')
|
||||
|
||||
self._submit_post_to_worker_pool(
|
||||
post_data,
|
||||
args_for_this_worker,
|
||||
1, # File threads per worker (1 for sequential batch)
|
||||
self.worker_to_gui_queue,
|
||||
ppw_expected_keys,
|
||||
{}
|
||||
)
|
||||
|
||||
self.fetched_posts_for_batch_update = []
|
||||
self.is_fetcher_thread_running = False
|
||||
self._check_if_all_work_is_done()
|
||||
|
||||
def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
|
||||
"""Helper to prepare and submit a single post processing task to the thread pool."""
|
||||
global PostProcessorWorker
|
||||
@ -5173,56 +5493,76 @@ class DownloaderApp (QWidget ):
|
||||
self ._filter_links_log ()
|
||||
|
||||
def cancel_download_button_action(self):
|
||||
"""
|
||||
Handles the user clicking the 'Cancel' button.
|
||||
This version forcefully shuts down thread pools.
|
||||
"""
|
||||
if not self._is_download_active() and not self.is_paused:
|
||||
self.log_signal.emit("ℹ️ Cancel button clicked, but no download is active.")
|
||||
return
|
||||
|
||||
if self.is_paused:
|
||||
self.log_signal.emit("❌ Cancellation requested while paused. Stopping all workers...")
|
||||
|
||||
if self._is_download_active() and hasattr(self.download_thread, 'cancel'):
|
||||
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
|
||||
self.download_thread.cancel()
|
||||
else:
|
||||
# Fallback for other download types
|
||||
self.cancellation_event.set()
|
||||
|
||||
# Update UI to "Cancelling" state
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
|
||||
if hasattr(self, 'reset_button'):
|
||||
self.reset_button.setEnabled(False)
|
||||
self.log_signal.emit("❌ Cancellation requested by user. Stopping all workers...")
|
||||
|
||||
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
if hasattr(self, 'reset_button'):
|
||||
self.reset_button.setEnabled(False)
|
||||
|
||||
# 1. Set the master cancellation event
|
||||
# This tells all workers to stop *cooperatively*
|
||||
if not self.cancellation_event.is_set():
|
||||
self.cancellation_event.set()
|
||||
|
||||
# Only call QThread-specific methods if the thread is a QThread
|
||||
# 2. Forcefully shut down QThreads
|
||||
if self.download_thread and hasattr(self.download_thread, 'requestInterruption'):
|
||||
self.download_thread.requestInterruption()
|
||||
self.log_signal.emit(" Signaled single download thread to interrupt.")
|
||||
|
||||
if self.thread_pool:
|
||||
self.log_signal.emit(" Signaling worker pool to cancel futures...")
|
||||
|
||||
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
|
||||
self.log_signal.emit(" Cancelling active External Link download thread...")
|
||||
self.external_link_download_thread.cancel()
|
||||
|
||||
# ... (add any other QThread .cancel() calls here if you have them) ...
|
||||
if isinstance(self.download_thread, NhentaiDownloadThread):
|
||||
self.log_signal.emit(" Signaling nhentai download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, BunkrDownloadThread):
|
||||
self.log_signal.emit(" Signaling Bunkr download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, Saint2DownloadThread):
|
||||
self.log_signal.emit(" Signaling Saint2 download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, EromeDownloadThread):
|
||||
self.log_signal.emit(" Signaling Erome download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, Hentai2readDownloadThread):
|
||||
self.log_signal.emit(" Signaling Hentai2Read download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
# 3. Forcefully shut down ThreadPoolExecutors
|
||||
# This is the critical fix for batch/update downloads
|
||||
if self.thread_pool:
|
||||
self.log_signal.emit(" Signaling worker pool to shut down...")
|
||||
# We use cancel_futures=True to actively stop pending tasks
|
||||
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
self.thread_pool = None
|
||||
self.active_futures = []
|
||||
self.log_signal.emit(" Worker pool shutdown initiated.")
|
||||
|
||||
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
|
||||
self.log_signal.emit(" Signaling retry worker pool to shut down...")
|
||||
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
self.retry_thread_pool = None
|
||||
self.active_retry_futures = []
|
||||
self.log_signal.emit(" Retry pool shutdown initiated.")
|
||||
|
||||
# 4. Manually trigger the 'finished' logic to reset the UI
|
||||
# This is safe because we just shut down all the threads
|
||||
self.download_finished(0, 0, True, [])
|
||||
|
||||
def _get_domain_for_service(self, service_name: str) -> str:
|
||||
"""Determines the base domain for a given service."""
|
||||
@ -5571,7 +5911,14 @@ class DownloaderApp (QWidget ):
|
||||
'target_post_id_from_initial_url':None ,
|
||||
'custom_folder_name':None ,
|
||||
'num_file_threads':1 ,
|
||||
|
||||
|
||||
# --- START: ADDED COOKIE FIX ---
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'cookie_text': self.cookie_text_input.text(),
|
||||
'selected_cookie_file': self.selected_cookie_filepath,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
# --- END: ADDED COOKIE FIX ---
|
||||
|
||||
'manga_date_file_counter_ref':None ,
|
||||
}
|
||||
|
||||
@ -5918,6 +6265,8 @@ class DownloaderApp (QWidget ):
|
||||
self.is_fetching_only = False
|
||||
self.fetched_posts_for_download = []
|
||||
self.is_ready_to_download_fetched = False
|
||||
self.fetched_posts_for_batch_update = []
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.allcomic_warning_shown = False
|
||||
|
||||
self.set_ui_enabled(True)
|
||||
@ -6270,21 +6619,16 @@ class DownloaderApp (QWidget ):
|
||||
return
|
||||
dialog = EmptyPopupDialog(self.app_base_dir, self)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
if dialog.update_profile_data:
|
||||
self.active_update_profile = dialog.update_profile_data
|
||||
self.link_input.setText(dialog.update_creator_name)
|
||||
self.favorite_download_queue.clear()
|
||||
|
||||
if 'settings' in self.active_update_profile:
|
||||
self.log_signal.emit(f"ℹ️ Applying saved settings from '{dialog.update_creator_name}' profile...")
|
||||
self._load_ui_from_settings_dict(self.active_update_profile['settings'])
|
||||
self.log_signal.emit(" Settings restored.")
|
||||
# --- NEW BATCH UPDATE LOGIC ---
|
||||
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
|
||||
self.active_update_profiles_list = dialog.update_profiles_list
|
||||
self.log_signal.emit(f"ℹ️ Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...")
|
||||
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
|
||||
self._start_batch_update_check(self.active_update_profiles_list)
|
||||
|
||||
self.log_signal.emit(f"ℹ️ Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
|
||||
self._update_button_states_and_connections()
|
||||
|
||||
# --- Original logic for adding creators to queue ---
|
||||
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
|
||||
self.active_update_profile = None
|
||||
self.active_update_profile = None # Ensure single update mode is off
|
||||
self.favorite_download_queue.clear()
|
||||
|
||||
for creator_data in dialog.selected_creators_for_queue:
|
||||
@ -6314,9 +6658,7 @@ class DownloaderApp (QWidget ):
|
||||
if hasattr(self, 'link_input'):
|
||||
self.last_link_input_text_for_queue_sync = self.link_input.text()
|
||||
|
||||
# Manually trigger the UI update now that the queue is populated and the dialog is closed.
|
||||
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
|
||||
|
||||
def _load_saved_cookie_settings(self):
|
||||
"""Loads and applies saved cookie settings on startup."""
|
||||
try:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user