From 7217bfdb39e89e13997ef0592d2d679e012aac40 Mon Sep 17 00:00:00 2001 From: Yuvi9587 <114073886+Yuvi9587@users.noreply.github.com> Date: Sun, 7 Sep 2025 04:56:08 -0700 Subject: [PATCH] Commit --- src/config/constants.py | 11 +- src/core/Hentai2read_client.py | 72 +++ src/core/workers.py | 2 + src/i18n/translator.py | 16 +- src/ui/dialogs/ErrorFilesDialog.py | 12 +- src/ui/dialogs/FutureSettingsDialog.py | 184 +++++- src/ui/dialogs/discord_pdf_generator.py | 52 +- src/ui/main_window.py | 772 ++++++++++++++++++++++-- src/utils/network_utils.py | 26 +- src/utils/resolution.py | 19 +- 10 files changed, 1023 insertions(+), 143 deletions(-) create mode 100644 src/core/Hentai2read_client.py diff --git a/src/config/constants.py b/src/config/constants.py index d317f5f..edbfc2a 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -1,4 +1,3 @@ -# --- Application Metadata --- CONFIG_ORGANIZATION_NAME = "KemonoDownloader" CONFIG_APP_NAME_MAIN = "ApplicationSettings" CONFIG_APP_NAME_TOUR = "ApplicationTour" @@ -9,7 +8,7 @@ STYLE_ORIGINAL_NAME = "original_name" STYLE_DATE_BASED = "date_based" STYLE_DATE_POST_TITLE = "date_post_title" STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" -STYLE_POST_ID = "post_id" # Add this line +STYLE_POST_ID = "post_id" MANGA_DATE_PREFIX_DEFAULT = "" # --- Download Scopes --- @@ -60,7 +59,11 @@ DOWNLOAD_LOCATION_KEY = "downloadLocationV1" RESOLUTION_KEY = "window_resolution" UI_SCALE_KEY = "ui_scale_factor" SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile" -FETCH_FIRST_KEY = "fetchAllPostsFirst" +FETCH_FIRST_KEY = "fetchAllPostsFirst" +# --- FIX: Add the missing key for the Discord token --- +DISCORD_TOKEN_KEY = "discord/token" + +POST_DOWNLOAD_ACTION_KEY = "postDownloadAction" # --- UI Constants and Identifiers --- HTML_PREFIX = "" @@ -120,4 +123,4 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = { # --- Duplicate Handling Modes --- DUPLICATE_HANDLING_HASH = "hash" -DUPLICATE_HANDLING_KEEP_ALL = "keep_all" \ No newline at end of file +DUPLICATE_HANDLING_KEEP_ALL = "keep_all" \ No newline at end of file diff --git a/src/core/Hentai2read_client.py b/src/core/Hentai2read_client.py new file mode 100644 index 0000000..38bbac5 --- /dev/null +++ b/src/core/Hentai2read_client.py @@ -0,0 +1,72 @@ +# src/core/Hentai2read_client.py + +import re +import os +import json +import requests +import cloudscraper +from bs4 import BeautifulSoup + +def fetch_hentai2read_data(url, logger, session): + """ + Scrapes a SINGLE Hentai2Read chapter page using a provided session. + """ + logger(f"Attempting to fetch chapter data from: {url}") + + try: + response = session.get(url, timeout=30) + response.raise_for_status() + + page_content_text = response.text + soup = BeautifulSoup(page_content_text, 'html.parser') + + album_title = "" + title_tags = soup.select('span[itemprop="name"]') + if title_tags: + album_title = title_tags[-1].text.strip() + + if not album_title: + title_tag = soup.select_one('h1.title') + if title_tag: + album_title = title_tag.text.strip() + + if not album_title: + logger("❌ Could not find album title on page.") + return None, None + + image_urls = [] + try: + start_index = page_content_text.index("'images' : ") + len("'images' : ") + end_index = page_content_text.index(",\n", start_index) + images_json_str = page_content_text[start_index:end_index] + image_paths = json.loads(images_json_str) + image_urls = ["https://hentaicdn.com/hentai" + part for part in image_paths] + except (ValueError, json.JSONDecodeError): + logger("❌ Could not find or parse image JSON data for this chapter.") + return None, None + + if not image_urls: + logger("❌ No image URLs found for this chapter.") + return None, None + + logger(f" Found {len(image_urls)} images for album '{album_title}'.") + + files_to_download = [] + for i, img_url in enumerate(image_urls): + page_num = i + 1 + extension = os.path.splitext(img_url)[1].split('?')[0] + if not extension: extension = ".jpg" + filename = f"{page_num:03d}{extension}" + files_to_download.append({'url': img_url, 'filename': filename}) + + return album_title, files_to_download + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + logger(f" Chapter not found (404 Error). This likely marks the end of the series.") + else: + logger(f"❌ An HTTP error occurred: {e}") + return None, None + except Exception as e: + logger(f"❌ An unexpected error occurred while fetching data: {e}") + return None, None diff --git a/src/core/workers.py b/src/core/workers.py index f7c9b82..c863f1b 100644 --- a/src/core/workers.py +++ b/src/core/workers.py @@ -848,6 +848,8 @@ class PostProcessorWorker: 'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, 'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post, 'forced_filename_override': filename_to_save_in_main_path, + 'service': self.service, + 'user_id': self.user_id } return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details finally: diff --git a/src/i18n/translator.py b/src/i18n/translator.py index b08ebfc..2347b92 100644 --- a/src/i18n/translator.py +++ b/src/i18n/translator.py @@ -2664,7 +2664,7 @@ translations ["en"]={ "use_cookie_checkbox_label": "Use cookie", "use_multithreading_checkbox_base_label": "Use multithreading", "show_external_links_checkbox_label": "Show external links in log", - "manga_comic_mode_checkbox_label": "Manga/Comic Mode", + "manga_comic_mode_checkbox_label": "Renaming Mode", "threads_label": "Threads:", "start_download_button_text": "⬇️ Start Download", "start_download_button_tooltip": "Click to start the download or link extraction process with the current settings.", @@ -2851,10 +2851,10 @@ translations ["en"]={ "cookie_browse_button_tooltip": "Browse for a cookie file (Netscape format, usually cookies.txt).\nThis will be used if 'Use cookie' is checked and the text field above is empty.", "page_range_label_text": "Page Range:", "start_page_input_placeholder": "Start", - "start_page_input_tooltip": "For creator URLs: Specify the starting page number for the download (e.g., 1, 2, 3).\nLeave empty or set to 1 to start from the first page.\nDisabled for single post URLs or in Manga/Comic Mode.", + "start_page_input_tooltip": "For creator URLs: Specify the starting page number for the download (e.g., 1, 2, 3).\nLeave empty or set to 1 to start from the first page.\nDisabled for single post URLs or in Renaming Mode.", "page_range_to_label_text": "to", "end_page_input_placeholder": "End", - "end_page_input_tooltip": "For creator URLs: Specify the ending page number for the download (e.g., 5, 10).\nLeave empty to download all pages from the start page.\nDisabled for single post URLs or in Manga/Comic Mode.", + "end_page_input_tooltip": "For creator URLs: Specify the ending page number for the download (e.g., 5, 10).\nLeave empty to download all pages from the start page.\nDisabled for single post URLs or in Renaming Mode.", "known_names_help_button_tooltip_text": "Open the application feature guide.", "future_settings_button_tooltip_text": "Open application settings (Theme, Language, etc.).", "link_search_button_tooltip_text": "Filter displayed links", @@ -2890,7 +2890,7 @@ translations ["en"]={ "tour_dialog_step1_title": "👋 Welcome!", "tour_dialog_step1_content": "Hello! This quick tour will guide you through the main features of Kemono Downloader, including recent updates like enhanced filtering, manga mode improvements, and cookie handling.\n", "tour_dialog_step2_title": "① Getting Started", - "tour_dialog_step2_content": "Let's start with the download basics:\n", + "tour_dialog_step2_content": "Let's start with the download basics:\n", "tour_dialog_step3_title": "② Filtering Downloads", "tour_dialog_step3_content": "Refine what you download with these filters (most are disabled in 'Links Only' or 'Archives Only' modes):\n", "tour_dialog_step4_title": "③ Favorite Mode (Alternate Downloading)", @@ -2898,7 +2898,7 @@ translations ["en"]={ "tour_dialog_step5_title": "④ Refining Downloads", "tour_dialog_step5_content": "More options to customize your downloads:\n", "tour_dialog_step6_title": "⑤ Organization & Performance", - "tour_dialog_step6_content": "Organize your downloads and manage performance:\n", + "tour_dialog_step6_content": "Organize your downloads and manage performance:\n", "tour_dialog_step7_title": "⑥ Common Errors & Troubleshooting", "tour_dialog_step7_content": "Sometimes downloads can run into issues. Here are some of the most common ones:\n", "tour_dialog_step8_title": "⑦ Logs & Final Controls", @@ -2908,7 +2908,7 @@ translations ["en"]={ "help_guide_instagram_tooltip": "Visit our Instagram page (Opens in browser)", "help_guide_discord_tooltip": "Join our Discord community (Opens in browser)", "help_guide_step1_title": "① Introduction & Main Inputs", - "help_guide_step1_content": "\n

This guide provides an overview of the features, fields, and buttons in the Kemono Downloader.

\n

Main Input Area (Top-Left)

\n", + "help_guide_step1_content": "\n

This guide provides an overview of the features, fields, and buttons in the Kemono Downloader.

\n

Main Input Area (Top-Left)

\n", "help_guide_step2_title": "② Filtering Downloads", "help_guide_step2_content": "\n

Filtering Downloads (Left Panel)

\n", "help_guide_step3_title": "③ Download Options & Settings", @@ -2916,11 +2916,11 @@ translations ["en"]={ "help_guide_step4_title": "④ Advanced Settings (Part 1)", "help_guide_step4_content": "

⚙️ Advanced Settings (Continued)

", "help_guide_step5_title": "⑤ Advanced Settings (Part 2) & Actions", - "help_guide_step5_content": "

⚙️ Advanced Settings (Continued)

\n

Main Actions (Left Panel)

\n", + "help_guide_step5_content": "

⚙️ Advanced Settings (Continued)

\n

Main Actions (Left Panel)

\n", "help_guide_step6_title": "⑥ Known Series/Characters List", "help_guide_step6_content": "\n

Managing the Known Series/Characters List (Bottom-Left)

\n

This section helps manage the Known.txt file, which is used for smart folder organization when 'Separate folders by Known.txt' is on, especially as a fallback if a post doesn't match your active 'Filter by Character(s)' input.

\n", "help_guide_step7_title": "⑦ Log Area & Controls", - "help_guide_step7_content": "\n

Log Area & Controls (Right Panel)

\n", + "help_guide_step7_content": "\n

Log Area & Controls (Right Panel)

\n", "help_guide_step8_title": "⑧ Favorite Mode & Future Features", "help_guide_step8_content": "\n

Favorite Mode (Downloading from your Kemono.su Favorites)

\n

This mode allows you to download content directly from artists you have favorited on Kemono.su.

\n", "help_guide_step9_title": "⑨ Key Files & Tour", diff --git a/src/ui/dialogs/ErrorFilesDialog.py b/src/ui/dialogs/ErrorFilesDialog.py index 899412c..a8f1fae 100644 --- a/src/ui/dialogs/ErrorFilesDialog.py +++ b/src/ui/dialogs/ErrorFilesDialog.py @@ -106,7 +106,17 @@ class ErrorFilesDialog(QDialog): post_title = error_info.get('post_title', 'Unknown Post') post_id = error_info.get('original_post_id_for_log', 'N/A') - item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})" + creator_name = "Unknown Creator" + service = error_info.get('service') + user_id = error_info.get('user_id') + + # Check if we have the necessary info and access to the cache + if service and user_id and hasattr(self.parent_app, 'creator_name_cache'): + creator_key = (service.lower(), str(user_id)) + # Look up the name, fall back to the user_id if not found + creator_name = self.parent_app.creator_name_cache.get(creator_key, user_id) + + item_text = f"File: {filename}\nCreator: {creator_name} - Post: '{post_title}' (ID: {post_id})" list_item = QListWidgetItem(item_text) list_item.setData(Qt.UserRole, error_info) list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable) diff --git a/src/ui/dialogs/FutureSettingsDialog.py b/src/ui/dialogs/FutureSettingsDialog.py index fde761c..d8534e6 100644 --- a/src/ui/dialogs/FutureSettingsDialog.py +++ b/src/ui/dialogs/FutureSettingsDialog.py @@ -4,24 +4,109 @@ import json import sys # --- PyQt5 Imports --- -from PyQt5.QtCore import Qt, QStandardPaths +from PyQt5.QtCore import Qt, QStandardPaths, QTimer from PyQt5.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox ) - # --- Local Application Imports --- from ...i18n.translator import get_translation from ...utils.resolution import get_dark_theme +from ..assets import get_app_icon_object + from ..main_window import get_app_icon_object from ...config.constants import ( THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY, RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY, COOKIE_TEXT_KEY, USE_COOKIE_KEY, - FETCH_FIRST_KEY + FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY ) from ...services.updater import UpdateChecker, UpdateDownloader +class CountdownMessageBox(QDialog): + """ + A custom message box that includes a countdown timer for the 'Yes' button, + which automatically accepts the dialog when the timer reaches zero. + """ + def __init__(self, title, text, countdown_seconds=10, parent_app=None, parent=None): + super().__init__(parent) + self.parent_app = parent_app + self.countdown = countdown_seconds + + # --- Basic Window Setup --- + self.setWindowTitle(title) + self.setModal(True) + app_icon = get_app_icon_object() + if app_icon and not app_icon.isNull(): + self.setWindowIcon(app_icon) + + self._init_ui(text) + self._apply_theme() + + # --- Timer Setup --- + self.timer = QTimer(self) + self.timer.setInterval(1000) # Tick every second + self.timer.timeout.connect(self._update_countdown) + self.timer.start() + + def _init_ui(self, text): + """Initializes the UI components of the dialog.""" + main_layout = QVBoxLayout(self) + + self.message_label = QLabel(text) + self.message_label.setWordWrap(True) + self.message_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(self.message_label) + + buttons_layout = QHBoxLayout() + buttons_layout.addStretch(1) + + self.yes_button = QPushButton() + self.yes_button.clicked.connect(self.accept) + self.yes_button.setDefault(True) + + self.no_button = QPushButton() + self.no_button.clicked.connect(self.reject) + + buttons_layout.addWidget(self.yes_button) + buttons_layout.addWidget(self.no_button) + buttons_layout.addStretch(1) + + main_layout.addLayout(buttons_layout) + + self._retranslate_ui() + self._update_countdown() # Initial text setup + + def _tr(self, key, default_text=""): + """Helper for translations.""" + if self.parent_app and hasattr(self.parent_app, 'current_selected_language'): + return get_translation(self.parent_app.current_selected_language, key, default_text) + return default_text + + def _retranslate_ui(self): + """Sets translated text for UI elements.""" + self.no_button.setText(self._tr("no_button_text", "No")) + # The 'yes' button text is handled by the countdown + + def _update_countdown(self): + """Updates the countdown and button text each second.""" + if self.countdown <= 0: + self.timer.stop() + self.accept() # Automatically accept when countdown finishes + return + + yes_text = self._tr("yes_button_text", "Yes") + self.yes_button.setText(f"{yes_text} ({self.countdown})") + self.countdown -= 1 + + def _apply_theme(self): + """Applies the current theme from the parent application.""" + if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark": + scale = getattr(self.parent_app, 'scale_factor', 1) + self.setStyleSheet(get_dark_theme(scale)) + else: + self.setStyleSheet("") + class FutureSettingsDialog(QDialog): """ A dialog for managing application-wide settings like theme, language, @@ -39,7 +124,7 @@ class FutureSettingsDialog(QDialog): screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800 scale_factor = screen_height / 800.0 - base_min_w, base_min_h = 420, 480 # Increased height for update section + base_min_w, base_min_h = 420, 520 # Increased height for new options scaled_min_w = int(base_min_w * scale_factor) scaled_min_h = int(base_min_h * scale_factor) self.setMinimumSize(scaled_min_w, scaled_min_h) @@ -55,7 +140,6 @@ class FutureSettingsDialog(QDialog): self.interface_group_box = QGroupBox() interface_layout = QGridLayout(self.interface_group_box) - # Theme, UI Scale, Language (unchanged)... self.theme_label = QLabel() self.theme_toggle_button = QPushButton() self.theme_toggle_button.clicked.connect(self._toggle_theme) @@ -87,21 +171,26 @@ class FutureSettingsDialog(QDialog): self.default_path_label = QLabel() self.save_path_button = QPushButton() - self.save_path_button.clicked.connect(self._save_cookie_and_path) + self.save_path_button.clicked.connect(self._save_settings) download_window_layout.addWidget(self.default_path_label, 1, 0) download_window_layout.addWidget(self.save_path_button, 1, 1) + self.post_download_action_label = QLabel() + self.post_download_action_combo = QComboBox() + self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed) + download_window_layout.addWidget(self.post_download_action_label, 2, 0) + download_window_layout.addWidget(self.post_download_action_combo, 2, 1) + self.save_creator_json_checkbox = QCheckBox() self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed) - download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2) + download_window_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2) self.fetch_first_checkbox = QCheckBox() self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed) - download_window_layout.addWidget(self.fetch_first_checkbox, 3, 0, 1, 2) + download_window_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2) main_layout.addWidget(self.download_window_group_box) - # --- NEW: Update Section --- self.update_group_box = QGroupBox() update_layout = QGridLayout(self.update_group_box) self.version_label = QLabel() @@ -112,7 +201,6 @@ class FutureSettingsDialog(QDialog): update_layout.addWidget(self.update_status_label, 0, 1) update_layout.addWidget(self.check_update_button, 1, 0, 1, 2) main_layout.addWidget(self.update_group_box) - # --- END: New Section --- main_layout.addStretch(1) @@ -129,28 +217,27 @@ class FutureSettingsDialog(QDialog): self.language_label.setText(self._tr("language_label", "Language:")) self.window_size_label.setText(self._tr("window_size_label", "Window Size:")) self.default_path_label.setText(self._tr("default_path_label", "Default Path:")) + self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:")) self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file")) self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)")) self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar.")) self._update_theme_toggle_button_text() - self.save_path_button.setText(self._tr("settings_save_cookie_path_button", "Save Cookie + Download Path")) - self.save_path_button.setToolTip(self._tr("settings_save_cookie_path_tooltip", "Save the current 'Download Location' and Cookie settings for future sessions.")) + self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token")) + self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions.")) self.ok_button.setText(self._tr("ok_button", "OK")) - # --- NEW: Translations for Update Section --- self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates")) current_version = self.parent_app.windowTitle().split(' v')[-1] self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}")) self.update_status_label.setText(self._tr("update_status_ready", "Ready to check.")) self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates")) - # --- END: New Translations --- - + self._populate_display_combo_boxes() self._populate_language_combo_box() + self._populate_post_download_action_combo() self._load_checkbox_states() def _check_for_updates(self): - """Starts the update check thread.""" self.check_update_button.setEnabled(False) self.update_status_label.setText(self._tr("update_status_checking", "Checking...")) current_version = self.parent_app.windowTitle().split(' v')[-1] @@ -189,7 +276,6 @@ class FutureSettingsDialog(QDialog): self.check_update_button.setEnabled(True) self.ok_button.setEnabled(True) - # --- (The rest of the file remains unchanged from your provided code) --- def _load_checkbox_states(self): self.save_creator_json_checkbox.blockSignals(True) should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) @@ -252,15 +338,9 @@ class FutureSettingsDialog(QDialog): self.ui_scale_combo_box.blockSignals(True) self.ui_scale_combo_box.clear() scales = [ - (0.5, "50%"), - (0.7, "70%"), - (0.9, "90%"), - (1.0, "100% (Default)"), - (1.25, "125%"), - (1.50, "150%"), - (1.75, "175%"), - (2.0, "200%") - ] + (0.5, "50%"), (0.7, "70%"), (0.9, "90%"), (1.0, "100% (Default)"), + (1.25, "125%"), (1.50, "150%"), (1.75, "175%"), (2.0, "200%") + ] current_scale = self.parent_app.settings.value(UI_SCALE_KEY, 1.0) for scale_val, scale_name in scales: self.ui_scale_combo_box.addItem(scale_name, scale_val) @@ -285,7 +365,7 @@ class FutureSettingsDialog(QDialog): ("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"), ("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"), ("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)") - ] + ] current_lang = self.parent_app.current_selected_language for lang_code, lang_name in languages: self.language_combo_box.addItem(lang_name, lang_code) @@ -305,14 +385,44 @@ class FutureSettingsDialog(QDialog): QMessageBox.information(self, self._tr("language_change_title", "Language Changed"), self._tr("language_change_message", "A restart is required...")) - def _save_cookie_and_path(self): + def _populate_post_download_action_combo(self): + """Populates the action dropdown and sets the current selection from settings.""" + self.post_download_action_combo.blockSignals(True) + self.post_download_action_combo.clear() + + actions = [ + (self._tr("action_off", "Off"), "off"), + (self._tr("action_notify", "Notify with Sound"), "notify"), + (self._tr("action_sleep", "Sleep"), "sleep"), + (self._tr("action_shutdown", "Shutdown"), "shutdown") + ] + + current_action = self.parent_app.settings.value(POST_DOWNLOAD_ACTION_KEY, "off") + + for text, key in actions: + self.post_download_action_combo.addItem(text, key) + if current_action == key: + self.post_download_action_combo.setCurrentIndex(self.post_download_action_combo.count() - 1) + + self.post_download_action_combo.blockSignals(False) + + def _post_download_action_changed(self): + """Saves the selected post-download action to settings.""" + selected_action = self.post_download_action_combo.currentData() + self.parent_app.settings.setValue(POST_DOWNLOAD_ACTION_KEY, selected_action) + self.parent_app.settings.sync() + + def _save_settings(self): path_saved = False cookie_saved = False + token_saved = False + if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input: current_path = self.parent_app.dir_input.text().strip() if current_path and os.path.isdir(current_path): self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path) path_saved = True + if hasattr(self.parent_app, 'use_cookie_checkbox'): use_cookie = self.parent_app.use_cookie_checkbox.isChecked() cookie_content = self.parent_app.cookie_text_input.text().strip() @@ -323,8 +433,20 @@ class FutureSettingsDialog(QDialog): else: self.parent_app.settings.setValue(USE_COOKIE_KEY, False) self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "") + + if (hasattr(self.parent_app, 'remove_from_filename_input') and + hasattr(self.parent_app, 'remove_from_filename_label_widget')): + + label_text = self.parent_app.remove_from_filename_label_widget.text() + if "Token" in label_text: + discord_token = self.parent_app.remove_from_filename_input.text().strip() + if discord_token: + self.parent_app.settings.setValue(DISCORD_TOKEN_KEY, discord_token) + token_saved = True + self.parent_app.settings.sync() - if path_saved or cookie_saved: - QMessageBox.information(self, "Settings Saved", "Settings have been saved.") + + if path_saved or cookie_saved or token_saved: + QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.") else: - QMessageBox.warning(self, "Nothing to Save", "No valid settings to save.") \ No newline at end of file + QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.") diff --git a/src/ui/dialogs/discord_pdf_generator.py b/src/ui/dialogs/discord_pdf_generator.py index ea2f100..49ab403 100644 --- a/src/ui/dialogs/discord_pdf_generator.py +++ b/src/ui/dialogs/discord_pdf_generator.py @@ -1,6 +1,7 @@ import os import re import datetime +import time try: from fpdf import FPDF FPDF_AVAILABLE = True @@ -29,7 +30,7 @@ except ImportError: FPDF = None PDF = None -def create_pdf_from_discord_messages(messages_data, server_name, channel_name, output_filename, font_path, logger=print): +def create_pdf_from_discord_messages(messages_data, server_name, channel_name, output_filename, font_path, logger=print, cancellation_event=None, pause_event=None): """ Creates a single PDF from a list of Discord message objects, formatted as a chat log. UPDATED to include clickable links for attachments and embeds. @@ -42,8 +43,20 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o logger(" No messages were found or fetched to create a PDF.") return False + # --- FIX: This helper function now correctly accepts and checks the event objects --- + def check_events(c_event, p_event): + """Helper to safely check for pause and cancel events.""" + if c_event and hasattr(c_event, 'is_cancelled') and c_event.is_cancelled: + return True # Stop + if p_event and hasattr(p_event, 'is_paused'): + while p_event.is_paused: + time.sleep(0.5) + if c_event and hasattr(c_event, 'is_cancelled') and c_event.is_cancelled: + return True + return False + logger(" Sorting messages by date (oldest first)...") - messages_data.sort(key=lambda m: m.get('published', '')) + messages_data.sort(key=lambda m: m.get('published', m.get('timestamp', ''))) pdf = PDF(server_name, channel_name) default_font_family = 'DejaVu' @@ -78,14 +91,19 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o logger(f" Starting PDF creation with {len(messages_data)} messages...") for i, message in enumerate(messages_data): + # --- FIX: Pass the event objects to the helper function --- + if i % 50 == 0: + if check_events(cancellation_event, pause_event): + logger(" PDF generation cancelled by user.") + return False + author = message.get('author', {}).get('global_name') or message.get('author', {}).get('username', 'Unknown User') - timestamp_str = message.get('published', '') + timestamp_str = message.get('published', message.get('timestamp', '')) content = message.get('content', '') attachments = message.get('attachments', []) embeds = message.get('embeds', []) try: - # Handle timezone information correctly if timestamp_str.endswith('Z'): timestamp_str = timestamp_str[:-1] + '+00:00' dt_obj = datetime.datetime.fromisoformat(timestamp_str) @@ -93,14 +111,12 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o except (ValueError, TypeError): formatted_timestamp = timestamp_str - # Draw a separator line if i > 0: pdf.ln(2) - pdf.set_draw_color(200, 200, 200) # Light grey line + pdf.set_draw_color(200, 200, 200) pdf.cell(0, 0, '', border='T') pdf.ln(2) - # Message Header pdf.set_font(default_font_family, 'B', 11) pdf.write(5, f"{author} ") pdf.set_font(default_font_family, '', 9) @@ -109,33 +125,31 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o pdf.set_text_color(0, 0, 0) pdf.ln(6) - # Message Content if content: pdf.set_font(default_font_family, '', 10) pdf.multi_cell(w=0, h=5, text=content) - # --- START: MODIFIED ATTACHMENT AND EMBED LOGIC --- if attachments or embeds: pdf.ln(1) pdf.set_font(default_font_family, '', 9) - pdf.set_text_color(22, 119, 219) # A nice blue for links + pdf.set_text_color(22, 119, 219) for att in attachments: - file_name = att.get('name', 'untitled') - file_path = att.get('path', '') - # Construct the full, clickable URL for the attachment - full_url = f"https://kemono.cr/data{file_path}" + file_name = att.get('filename', 'untitled') + full_url = att.get('url', '#') pdf.write(5, text=f"[Attachment: {file_name}]", link=full_url) - pdf.ln() # New line after each attachment + pdf.ln() for embed in embeds: embed_url = embed.get('url', 'no url') - # The embed URL is already a full URL pdf.write(5, text=f"[Embed: {embed_url}]", link=embed_url) - pdf.ln() # New line after each embed + pdf.ln() - pdf.set_text_color(0, 0, 0) # Reset color to black - # --- END: MODIFIED ATTACHMENT AND EMBED LOGIC --- + pdf.set_text_color(0, 0, 0) + + if check_events(cancellation_event, pause_event): + logger(" PDF generation cancelled by user before final save.") + return False try: pdf.output(output_filename) diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 211bc41..a9eaab1 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -2,6 +2,7 @@ import sys import os import time import queue +import random import traceback import html import http @@ -41,6 +42,7 @@ from ..core.nhentai_client import fetch_nhentai_gallery from ..core.bunkr_client import fetch_bunkr_data from ..core.saint2_client import fetch_saint2_data from ..core.erome_client import fetch_erome_data +from ..core.Hentai2read_client import fetch_hentai2read_data from .assets import get_app_icon_object from ..config.constants import * from ..utils.file_utils import KNOWN_NAMES, clean_folder_name @@ -53,7 +55,7 @@ from .dialogs.CookieHelpDialog import CookieHelpDialog from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog from .dialogs.KnownNamesFilterDialog import KnownNamesFilterDialog from .dialogs.HelpGuideDialog import HelpGuideDialog -from .dialogs.FutureSettingsDialog import FutureSettingsDialog +from .dialogs.FutureSettingsDialog import FutureSettingsDialog, CountdownMessageBox from .dialogs.ErrorFilesDialog import ErrorFilesDialog from .dialogs.DownloadHistoryDialog import DownloadHistoryDialog from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog @@ -67,6 +69,10 @@ from .dialogs.SupportDialog import SupportDialog from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog from .dialogs.MultipartScopeDialog import MultipartScopeDialog +_ff_ver = (datetime.date.today().toordinal() - 735506) // 28 +USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; " + f"rv:{_ff_ver}.0) Gecko/20100101 Firefox/{_ff_ver}.0") + class DynamicFilterHolder: """A thread-safe class to hold and update character filters during a download.""" def __init__(self, initial_filters=None): @@ -286,7 +292,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.0.0") + self.setWindowTitle("Kemono Downloader v7.1.0") setup_ui(self) self._connect_signals() if hasattr(self, 'character_input'): @@ -305,6 +311,127 @@ class DownloaderApp (QWidget ): self._check_for_interrupted_session() self._cleanup_after_update() + def _run_discord_file_download_thread(self, session, server_id, channel_id, token, output_dir, message_limit=None): + """ + Runs in a background thread to fetch and download all files from a Discord channel. + """ + def queue_logger(message): + self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)}) + + def queue_progress_label_update(message): + self.worker_to_gui_queue.put({'type': 'set_progress_label', 'payload': (message,)}) + + def check_events(): + if self.cancellation_event.is_set(): + return True # Stop + while self.pause_event.is_set(): + time.sleep(0.5) # Wait while paused + if self.cancellation_event.is_set(): + return True # Allow cancelling while paused + return False # Continue + + download_count = 0 + skip_count = 0 + + try: + queue_logger("=" * 40) + queue_logger(f"🚀 Starting Discord download for channel: {channel_id}") + queue_progress_label_update("Fetching messages...") + + def fetch_discord_api(endpoint): + headers = { + 'Authorization': token, + 'User-Agent': USERAGENT_FIREFOX, + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + } + try: + response = session.get(f"https://discord.com/api/v10{endpoint}", headers=headers) + response.raise_for_status() + return response.json() + except Exception: + return None + + last_message_id = None + all_messages = [] + + while True: + if check_events(): break + + url_endpoint = f"/channels/{channel_id}/messages?limit=100" + if last_message_id: + url_endpoint += f"&before={last_message_id}" + + message_batch = fetch_discord_api(url_endpoint) + if not message_batch: + break + + all_messages.extend(message_batch) + + if message_limit and len(all_messages) >= message_limit: + queue_logger(f" Reached message limit of {message_limit}. Halting fetch.") + all_messages = all_messages[:message_limit] + break + + last_message_id = message_batch[-1]['id'] + queue_progress_label_update(f"Fetched {len(all_messages)} messages...") + time.sleep(1) + + if self.cancellation_event.is_set(): + self.finished_signal.emit(0, 0, True, []) + return + + queue_progress_label_update(f"Collected {len(all_messages)} messages. Starting downloads...") + total_attachments = sum(len(m.get('attachments', [])) for m in all_messages) + + for message in reversed(all_messages): + if check_events(): break + for attachment in message.get('attachments', []): + if check_events(): break + + file_url = attachment['url'] + original_filename = attachment['filename'] + filepath = os.path.join(output_dir, original_filename) + filename_to_use = original_filename + + counter = 1 + base_name, extension = os.path.splitext(original_filename) + while os.path.exists(filepath): + filename_to_use = f"{base_name} ({counter}){extension}" + filepath = os.path.join(output_dir, filename_to_use) + counter += 1 + + if filename_to_use != original_filename: + queue_logger(f" -> Duplicate name '{original_filename}'. Saving as '{filename_to_use}'.") + + try: + queue_logger(f" Downloading ({download_count+1}/{total_attachments}): '{filename_to_use}'...") + # --- FIX: Stream the download in chunks for responsive controls --- + response = requests.get(file_url, stream=True, timeout=60) + response.raise_for_status() + + download_cancelled = False + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if check_events(): + download_cancelled = True + break + f.write(chunk) + + if download_cancelled: + queue_logger(f" Download cancelled for '{filename_to_use}'. Deleting partial file.") + if os.path.exists(filepath): + os.remove(filepath) + continue # Move to the next attachment + + download_count += 1 + except Exception as e: + queue_logger(f" ❌ Failed to download '{filename_to_use}': {e}") + skip_count += 1 + + finally: + self.finished_signal.emit(download_count, skip_count, self.cancellation_event.is_set(), []) + def _cleanup_after_update(self): """Deletes the old executable after a successful update.""" try: @@ -805,7 +932,7 @@ class DownloaderApp (QWidget ): if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setText (self ._tr ("use_cookie_checkbox_label","Use Cookie")) if hasattr (self ,'use_multithreading_checkbox'):self .update_multithreading_label (self .thread_count_input .text ()if hasattr (self ,'thread_count_input')else "1") if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setText (self ._tr ("show_external_links_checkbox_label","Show External Links in Log")) - if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Manga/Comic Mode")) + if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Renaming Mode")) if hasattr (self ,'thread_count_label'):self .thread_count_label .setText (self ._tr ("threads_label","Threads:")) if hasattr (self ,'character_input'): @@ -1202,64 +1329,83 @@ class DownloaderApp (QWidget ): ) pdf_thread.start() - def _run_discord_pdf_creation_thread(self, api_url, server_id, channel_id, output_filepath): + def _run_discord_pdf_creation_thread(self, session, api_url, server_id, channel_id, output_filepath, message_limit=None): def queue_logger(message): self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)}) def queue_progress_label_update(message): self.worker_to_gui_queue.put({'type': 'set_progress_label', 'payload': (message,)}) + token = self.remove_from_filename_input.text().strip() + headers = { + 'Authorization': token, + 'User-Agent': USERAGENT_FIREFOX, + } + self.set_ui_enabled(False) queue_logger("=" * 40) queue_logger(f"🚀 Starting Discord PDF export for: {api_url}") queue_progress_label_update("Fetching messages...") all_messages = [] - cookies = prepare_cookies_for_request( - self.use_cookie_checkbox.isChecked(), self.cookie_text_input.text(), - self.selected_cookie_filepath, self.app_base_dir, queue_logger # Use safe logger - ) - channels_to_process = [] server_name_for_pdf = server_id if channel_id: channels_to_process.append({'id': channel_id, 'name': channel_id}) else: - channels = fetch_server_channels(server_id, queue_logger, cookies) # Use safe logger - if channels: - channels_to_process = channels - # In a real scenario, you'd get the server name from an API. We'll use the ID. - server_name_for_pdf = server_id - else: - queue_logger(f"❌ Could not find any channels for server {server_id}.") - self.worker_to_gui_queue.put({'type': 'set_ui_enabled', 'payload': (True,)}) - return + # This logic can be expanded later to fetch all channels in a server if needed + pass - # Fetch messages for all required channels for i, channel in enumerate(channels_to_process): queue_progress_label_update(f"Fetching from channel {i+1}/{len(channels_to_process)}: #{channel.get('name', '')}") - message_generator = fetch_channel_messages(channel['id'], queue_logger, self.cancellation_event, self.pause_event, cookies) # Use safe logger - for message_batch in message_generator: + last_message_id = None + while not self.cancellation_event.is_set(): + url_endpoint = f"/channels/{channel['id']}/messages?limit=100" + if last_message_id: + url_endpoint += f"&before={last_message_id}" + + try: + resp = session.get(f"https://discord.com/api/v10{url_endpoint}", headers=headers) + resp.raise_for_status() + message_batch = resp.json() + except Exception: + message_batch = [] + + if not message_batch: + break + all_messages.extend(message_batch) + + if message_limit and len(all_messages) >= message_limit: + queue_logger(f" Reached message limit of {message_limit}. Halting fetch.") + all_messages = all_messages[:message_limit] + break + + last_message_id = message_batch[-1]['id'] + queue_progress_label_update(f"Fetched {len(all_messages)} messages...") + time.sleep(1) + + if message_limit and len(all_messages) >= message_limit: + break queue_progress_label_update(f"Collected {len(all_messages)} total messages. Generating PDF...") - # Determine font path + all_messages.reverse() + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): base_path = sys._MEIPASS else: base_path = self.app_base_dir font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf') - # Generate the PDF success = create_pdf_from_discord_messages( all_messages, server_name_for_pdf, channels_to_process[0].get('name', channel_id) if len(channels_to_process) == 1 else "All Channels", output_filepath, font_path, - logger=queue_logger # Use safe logger + logger=queue_logger ) if success: @@ -1267,9 +1413,7 @@ class DownloaderApp (QWidget ): else: queue_progress_label_update(f"❌ PDF export failed. Check log for details.") - queue_logger("=" * 40) - # Safely re-enable the UI from the main thread via the queue - self.worker_to_gui_queue.put({'type': 'set_ui_enabled', 'payload': (True,)}) + self.finished_signal.emit(0, len(all_messages), self.cancellation_event.is_set(), []) def save_known_names(self): """ @@ -3149,7 +3293,8 @@ class DownloaderApp (QWidget ): self.use_cookie_checkbox, self.keep_duplicates_checkbox, self.date_prefix_checkbox, self.manga_rename_toggle_button, self.manga_date_prefix_input, self.multipart_toggle_button, self.custom_folder_input, self.custom_folder_label, - self.discord_scope_toggle_button, self.save_discord_as_pdf_btn + self.discord_scope_toggle_button + # --- FIX: REMOVED self.save_discord_as_pdf_btn from this list --- ] enable_state = not is_specialized @@ -3189,21 +3334,42 @@ class DownloaderApp (QWidget ): url_text = self.link_input.text().strip() service, _, _ = extract_post_info(url_text) - - # Handle specialized downloaders (Bunkr, nhentai) + + # --- FIX: Use two separate flags for better control --- + # This is true for BOTH kemono.cr/discord and discord.com + is_any_discord_url = (service == 'discord') + # This is ONLY true for official discord.com + is_official_discord_url = 'discord.com' in url_text and is_any_discord_url + + if is_official_discord_url: + # Show the token input only for the official site + self.remove_from_filename_label_widget.setText("🔑 Discord Token:") + self.remove_from_filename_input.setPlaceholderText("Enter your Discord Authorization Token here") + self.remove_from_filename_input.setEchoMode(QLineEdit.Password) + saved_token = self.settings.value(DISCORD_TOKEN_KEY, "") + if saved_token: + self.remove_from_filename_input.setText(saved_token) + else: + # Revert to the standard input for Kemono, Coomer, etc. + self.remove_from_filename_label_widget.setText(self._tr("remove_words_from_name_label", "✂️ Remove Words from name:")) + self.remove_from_filename_input.setPlaceholderText(self._tr("remove_from_filename_input_placeholder_text", "e.g., patreon, HD")) + self.remove_from_filename_input.setEchoMode(QLineEdit.Normal) + + # Handle other specialized downloaders (Bunkr, nhentai, etc.) is_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text is_erome = 'erome.com' in url_text - is_specialized = service in ['bunkr', 'nhentai'] or is_saint2 or is_erome + is_specialized = service in ['bunkr', 'nhentai', 'hentai2read'] or is_saint2 or is_erome self._set_ui_for_specialized_downloader(is_specialized) - # Handle Discord UI - is_discord = (service == 'discord') - self.discord_scope_toggle_button.setVisible(is_discord) - self.save_discord_as_pdf_btn.setVisible(is_discord) + # --- FIX: Show the Scope button for ANY Discord URL (Kemono or official) --- + self.discord_scope_toggle_button.setVisible(is_any_discord_url) + if hasattr(self, 'discord_message_limit_input'): + # Only show the message limit for the official site, as it's an API feature + self.discord_message_limit_input.setVisible(is_official_discord_url) - if is_discord: + if is_any_discord_url: self._update_discord_scope_button_text() - elif not is_specialized: # Don't change button text for specialized downloaders + elif not is_specialized: self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) def _update_discord_scope_button_text(self): @@ -3475,6 +3641,72 @@ class DownloaderApp (QWidget ): service, id1, id2 = extract_post_info(api_url) + if 'discord.com' in api_url and service == 'discord': + server_id, channel_id = id1, id2 + token = self.remove_from_filename_input.text().strip() + output_dir = self.dir_input.text().strip() + + if not token or not output_dir: + QMessageBox.critical(self, "Input Error", "A Discord Token and Download Location are required.") + return False + + limit_text = self.discord_message_limit_input.text().strip() + message_limit = int(limit_text) if limit_text else None + if message_limit: + self.log_signal.emit(f"ℹ️ Applying message limit: will fetch up to {message_limit} latest messages.") + + mode = 'pdf' if self.discord_download_scope == 'messages' else 'files' + + # 1. Create the thread object + self.download_thread = DiscordDownloadThread( + mode=mode, session=requests.Session(), token=token, output_dir=output_dir, + server_id=server_id, channel_id=channel_id, url=api_url, limit=message_limit, parent=self + ) + + # 2. Connect its signals to the main window's functions + self.download_thread.progress_signal.connect(self.handle_main_log) + self.download_thread.progress_label_signal.connect(self.progress_label.setText) + self.download_thread.finished_signal.connect(self.download_finished) + + # --- FIX: Start the thread BEFORE updating the UI --- + # 3. Start the download process in the background + self.download_thread.start() + + # 4. NOW, update the UI. The app knows a download is active. + self.set_ui_enabled(False) + self._update_button_states_and_connections() + + return True + + if service == 'hentai2read': + self.log_signal.emit("=" * 40) + self.log_signal.emit(f"🚀 Detected Hentai2Read gallery: {id1}") + + if not effective_output_dir_for_run or not os.path.isdir(effective_output_dir_for_run): + QMessageBox.critical(self, "Input Error", "A valid Download Location is required.") + return False + + self.set_ui_enabled(False) + self.download_thread = Hentai2readDownloadThread( + base_url="https://hentai2read.com", + manga_slug=id1, + chapter_num=id2, + output_dir=effective_output_dir_for_run, + pause_event=self.pause_event, + parent=self + ) + + self.download_thread.progress_signal.connect(self.handle_main_log) + self.download_thread.file_progress_signal.connect(self.update_file_progress_display) + self.download_thread.overall_progress_signal.connect(self.update_progress_display) + self.download_thread.finished_signal.connect( + lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, []) + ) + self.download_thread.start() + self._update_button_states_and_connections() + return True + + if service == 'nhentai': gallery_id = id1 self.log_signal.emit("=" * 40) @@ -3874,11 +4106,11 @@ class DownloaderApp (QWidget ): msg_box.setIcon(QMessageBox.Warning) msg_box.setWindowTitle("Manga Mode & Page Range Warning") msg_box.setText( - "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n" - "Manga Mode processes posts from oldest to newest across all available pages by default.\n" - "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n" - "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n" - "Do you want to proceed with this page range in Manga Mode?" + "You have enabled Renaming Mode with a sequential naming style (Date Based or Title + G.Num) and also specified a Page Range.\n\n" + "These modes rely on processing all posts from the beginning to create a correct sequence. " + "Using a page range may result in an incomplete or incorrectly ordered download.\n\n" + "It is recommended to use these styles without a page range.\n\n" + "Do you want to proceed anyway?" ) proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole) @@ -4345,7 +4577,8 @@ class DownloaderApp (QWidget ): self.discord_scope_toggle_button.setVisible(is_discord) if hasattr(self, 'save_discord_as_pdf_btn'): self.save_discord_as_pdf_btn.setVisible(is_discord) - + if hasattr(self, 'discord_message_limit_input'): + self.discord_message_limit_input.setVisible(is_discord) if is_discord: self._update_discord_scope_button_text() else: @@ -4909,16 +5142,29 @@ class DownloaderApp (QWidget ): self .update_ui_for_subfolders (subfolders_currently_on ) self ._handle_favorite_mode_toggle (is_fav_mode_active ) - def _handle_pause_resume_action (self ): - if self ._is_download_active (): - self .is_paused =not self .is_paused - if self .is_paused : - if self .pause_event :self .pause_event .set () - self .log_signal .emit ("ℹ️ Download paused by user. Some settings can now be changed for subsequent operations.") - else : - if self .pause_event :self .pause_event .clear () - self .log_signal .emit ("ℹ️ Download resumed by user.") - self .set_ui_enabled (False ) + def _handle_pause_resume_action(self): + # --- FIX: Simplified and corrected the pause/resume logic --- + if not self._is_download_active(): + return + + # Toggle the main app's pause state tracker + self.is_paused = not self.is_paused + + # Call the correct method on the thread based on the new state + if isinstance(self.download_thread, DiscordDownloadThread): + if self.is_paused: + self.download_thread.pause() + else: + self.download_thread.resume() + else: + # Fallback for older download types + if self.is_paused: + self.pause_event.set() + else: + self.pause_event.clear() + + # This call correctly updates the button's text to "Pause" or "Resume" + self.set_ui_enabled(False) def _perform_soft_ui_reset (self ,preserve_url =None ,preserve_dir =None ): """Resets UI elements and some state to app defaults, then applies preserved inputs.""" @@ -5016,16 +5262,12 @@ class DownloaderApp (QWidget ): self ._filter_links_log () def cancel_download_button_action(self): - """ - Signals all active download processes to cancel but DOES NOT reset the UI. - The UI reset is now handled by the 'download_finished' method. - """ - if self.cancellation_event.is_set(): - self.log_signal.emit("ℹ️ Cancellation is already in progress.") - return - - self.log_signal.emit("⚠️ Requesting cancellation of download process...") - self.cancellation_event.set() + 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) @@ -5064,6 +5306,10 @@ class DownloaderApp (QWidget ): 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() + def _get_domain_for_service(self, service_name: str) -> str: """Determines the base domain for a given service.""" if not isinstance(service_name, str): @@ -5205,13 +5451,74 @@ class DownloaderApp (QWidget ): self.retryable_failed_files_info.clear() self.is_fetcher_thread_running = False + + # --- This is where the post-download action is triggered --- + if not cancelled_by_user and not self.is_processing_favorites_queue: + self._execute_post_download_action() self.set_ui_enabled(True) self._update_button_states_and_connections() self.cancellation_message_logged_this_session = False self.active_update_profile = None finally: - pass + self.is_finishing = False + self.finish_lock.release() + + def _execute_post_download_action(self): + """Checks the settings and performs the chosen action after downloads complete.""" + action = self.settings.value(POST_DOWNLOAD_ACTION_KEY, "off") + + if action == "off": + return + + elif action == "notify": + QApplication.beep() + self.log_signal.emit("✅ Download complete! Notification sound played.") + return + + # --- FIX: Ensure confirm_title is defined before it is used --- + confirm_title = self._tr("action_confirmation_title", "Action After Download") + confirm_text = "" + + if action == "sleep": + confirm_text = self._tr("confirm_sleep_text", "All downloads are complete. The computer will now go to sleep.") + elif action == "shutdown": + confirm_text = self._tr("confirm_shutdown_text", "All downloads are complete. The computer will now shut down.") + + dialog = CountdownMessageBox( + title=confirm_title, + text=confirm_text, + countdown_seconds=10, + parent_app=self, + parent=self + ) + + if dialog.exec_() == QDialog.Accepted: + # The rest of the logic only runs if the dialog is accepted (by click or timeout) + self.log_signal.emit(f"ℹ️ Performing post-download action: {action.capitalize()}") + try: + if sys.platform == "win32": + if action == "sleep": + os.system("powercfg -hibernate off") + os.system("rundll32.exe powrprof.dll,SetSuspendState 0,1,0") + os.system("powercfg -hibernate on") + elif action == "shutdown": + os.system("shutdown /s /t 1") + elif sys.platform == "darwin": # macOS + if action == "sleep": + os.system("pmset sleepnow") + elif action == "shutdown": + os.system("osascript -e 'tell app \"System Events\" to shut down'") + else: # Linux + if action == "sleep": + os.system("systemctl suspend") + elif action == "shutdown": + os.system("systemctl poweroff") + except Exception as e: + self.log_signal.emit(f"❌ Failed to execute post-download action '{action}': {e}") + else: + # This block runs if the user clicks "No" + self.log_signal.emit(f"ℹ️ Post-download '{action}' cancelled by user.") def _handle_keep_duplicates_toggled(self, checked): """Shows the duplicate handling dialog when the checkbox is checked.""" @@ -6178,6 +6485,190 @@ class DownloaderApp (QWidget ): # Use a QTimer to avoid deep recursion and correctly move to the next item. QTimer.singleShot(100, self._process_next_favorite_download) +class DiscordDownloadThread(QThread): + """A dedicated QThread for handling all official Discord downloads.""" + progress_signal = pyqtSignal(str) + progress_label_signal = pyqtSignal(str) + finished_signal = pyqtSignal(int, int, bool, list) + + def __init__(self, mode, session, token, output_dir, server_id, channel_id, url, limit=None, parent=None): + super().__init__(parent) + self.mode = mode + self.session = session + self.token = token + self.output_dir = output_dir + self.server_id = server_id + self.channel_id = channel_id + self.api_url = url + self.message_limit = limit + + self.is_cancelled = False + self.is_paused = False + + def run(self): + if self.mode == 'pdf': + self._run_pdf_creation() + else: + self._run_file_download() + + def cancel(self): + self.progress_signal.emit(" Cancellation signal received by Discord thread.") + self.is_cancelled = True + + def pause(self): + self.progress_signal.emit(" Pausing Discord download...") + self.is_paused = True + + def resume(self): + self.progress_signal.emit(" Resuming Discord download...") + self.is_paused = False + + def _check_events(self): + if self.is_cancelled: + return True + while self.is_paused: + time.sleep(0.5) + if self.is_cancelled: + return True + return False + + def _fetch_all_messages(self): + all_messages = [] + last_message_id = None + headers = {'Authorization': self.token, 'User-Agent': USERAGENT_FIREFOX} + + while True: + if self._check_events(): break + + endpoint = f"/channels/{self.channel_id}/messages?limit=100" + if last_message_id: + endpoint += f"&before={last_message_id}" + + try: + # This is a blocking call, but it has a timeout + resp = self.session.get(f"https://discord.com/api/v10{endpoint}", headers=headers, timeout=30) + resp.raise_for_status() + message_batch = resp.json() + except Exception as e: + self.progress_signal.emit(f" ❌ Error fetching message batch: {e}") + break + + if not message_batch: + break + + all_messages.extend(message_batch) + + if self.message_limit and len(all_messages) >= self.message_limit: + self.progress_signal.emit(f" Reached message limit of {self.message_limit}. Halting fetch.") + all_messages = all_messages[:self.message_limit] + break + + last_message_id = message_batch[-1]['id'] + self.progress_label_signal.emit(f"Fetched {len(all_messages)} messages...") + time.sleep(1) # API Rate Limiting + + return all_messages + + def _run_pdf_creation(self): + # ... (This method remains the same as the previous version) + self.progress_signal.emit("=" * 40) + self.progress_signal.emit(f"🚀 Starting Discord PDF export for: {self.api_url}") + self.progress_label_signal.emit("Fetching messages...") + + all_messages = self._fetch_all_messages() + + if self.is_cancelled: + self.finished_signal.emit(0, 0, True, []) + return + + self.progress_label_signal.emit(f"Collected {len(all_messages)} total messages. Generating PDF...") + all_messages.reverse() + + base_path = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf') + output_filepath = os.path.join(self.output_dir, f"discord_{self.server_id}_{self.channel_id or 'server'}.pdf") + + # The PDF generator itself now also checks for events + success = create_pdf_from_discord_messages( + all_messages, self.server_id, self.channel_id, + output_filepath, font_path, logger=self.progress_signal.emit, + cancellation_event=self, pause_event=self + ) + + if success: + self.progress_label_signal.emit(f"✅ PDF export complete!") + elif not self.is_cancelled: + self.progress_label_signal.emit(f"❌ PDF export failed. Check log for details.") + + self.finished_signal.emit(0, len(all_messages), self.is_cancelled, []) + + def _run_file_download(self): + # ... (This method remains the same as the previous version) + download_count = 0 + skip_count = 0 + try: + self.progress_signal.emit("=" * 40) + self.progress_signal.emit(f"🚀 Starting Discord download for channel: {self.channel_id}") + self.progress_label_signal.emit("Fetching messages...") + all_messages = self._fetch_all_messages() + + if self.is_cancelled: + self.finished_signal.emit(0, 0, True, []) + return + + self.progress_label_signal.emit(f"Collected {len(all_messages)} messages. Starting downloads...") + total_attachments = sum(len(m.get('attachments', [])) for m in all_messages) + + for message in reversed(all_messages): + if self._check_events(): break + for attachment in message.get('attachments', []): + if self._check_events(): break + + file_url = attachment['url'] + original_filename = attachment['filename'] + filepath = os.path.join(self.output_dir, original_filename) + filename_to_use = original_filename + + counter = 1 + base_name, extension = os.path.splitext(original_filename) + while os.path.exists(filepath): + filename_to_use = f"{base_name} ({counter}){extension}" + filepath = os.path.join(self.output_dir, filename_to_use) + counter += 1 + + if filename_to_use != original_filename: + self.progress_signal.emit(f" -> Duplicate name '{original_filename}'. Saving as '{filename_to_use}'.") + + try: + self.progress_signal.emit(f" Downloading ({download_count+1}/{total_attachments}): '{filename_to_use}'...") + response = requests.get(file_url, stream=True, timeout=60) + response.raise_for_status() + + download_cancelled_mid_file = False + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if self._check_events(): + download_cancelled_mid_file = True + break + f.write(chunk) + + if download_cancelled_mid_file: + self.progress_signal.emit(f" Download cancelled for '{filename_to_use}'. Deleting partial file.") + if os.path.exists(filepath): + os.remove(filepath) + continue + + download_count += 1 + except Exception as e: + self.progress_signal.emit(f" ❌ Failed to download '{filename_to_use}': {e}") + skip_count += 1 + finally: + self.finished_signal.emit(download_count, skip_count, self.is_cancelled, []) + + def cancel(self): + self.is_cancelled = True + self.progress_signal.emit(" Cancellation signal received by Discord thread.") + class Saint2DownloadThread(QThread): """A dedicated QThread for handling saint2.su downloads.""" progress_signal = pyqtSignal(str) @@ -6497,6 +6988,159 @@ class BunkrDownloadThread(QThread): self.is_cancelled = True self.progress_signal.emit(" Cancellation signal received by Bunkr thread.") +class Hentai2readDownloadThread(QThread): + """ + A dedicated QThread for Hentai2Read that uses a two-phase process: + 1. Fetch Phase: Scans all chapters to get total image count. + 2. Download Phase: Downloads all found images with overall progress. + """ + progress_signal = pyqtSignal(str) + file_progress_signal = pyqtSignal(str, object) + finished_signal = pyqtSignal(int, int, bool) + overall_progress_signal = pyqtSignal(int, int) + + def __init__(self, base_url, manga_slug, chapter_num, output_dir, pause_event, parent=None): + super().__init__(parent) + self.base_url = base_url + self.manga_slug = manga_slug + self.start_chapter = int(chapter_num) if chapter_num else 1 + self.output_dir = output_dir + self.pause_event = pause_event + self.is_cancelled = False + # Store the original chapter number to detect single-chapter mode + self.original_chapter_num = chapter_num + + def _check_pause(self): + if self.is_cancelled: return True + if self.pause_event and self.pause_event.is_set(): + self.progress_signal.emit(" Download paused...") + while self.pause_event.is_set(): + if self.is_cancelled: return True + time.sleep(0.5) + self.progress_signal.emit(" Download resumed.") + return self.is_cancelled + + def run(self): + # --- SETUP --- + is_single_chapter_mode = self.original_chapter_num is not None + + self.progress_signal.emit("=" * 40) + self.progress_signal.emit(f"🚀 Starting Hentai2Read Download for: {self.manga_slug}") + + session = cloudscraper.create_scraper( + browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True} + ) + + # --- PHASE 1: FETCH METADATA FOR ALL CHAPTERS --- + self.progress_signal.emit("--- Phase 1: Fetching metadata for all chapters... ---") + all_chapters_to_download = [] + chapter_counter = self.start_chapter + + while True: + if self._check_pause(): + self.finished_signal.emit(0, 0, True) + return + + chapter_url = f"{self.base_url}/{self.manga_slug}/{chapter_counter}/" + album_name, files_to_download = fetch_hentai2read_data(chapter_url, self.progress_signal.emit, session) + + if not files_to_download: + break # End of series found + + all_chapters_to_download.append({ + 'album_name': album_name, + 'files': files_to_download, + 'chapter_num': chapter_counter, + 'chapter_url': chapter_url + }) + + if is_single_chapter_mode: + break # If user specified one chapter, only fetch that one + chapter_counter += 1 + + if self._check_pause(): + self.finished_signal.emit(0, 0, True) + return + + # --- PHASE 2: CALCULATE TOTALS & START DOWNLOAD --- + if not all_chapters_to_download: + self.progress_signal.emit("❌ No downloadable chapters found for this series.") + self.finished_signal.emit(0, 0, self.is_cancelled) + return + + total_images = sum(len(chap['files']) for chap in all_chapters_to_download) + self.progress_signal.emit(f"✅ Fetch complete. Found {len(all_chapters_to_download)} chapter(s) with a total of {total_images} images.") + self.progress_signal.emit("--- Phase 2: Starting image downloads... ---") + + self.overall_progress_signal.emit(total_images, 0) + + grand_total_dl = 0 + grand_total_skip = 0 + images_processed = 0 + + for chapter_data in all_chapters_to_download: + if self._check_pause(): break + + chapter_album_name = chapter_data['album_name'] + self.progress_signal.emit("-" * 40) + self.progress_signal.emit(f"Downloading Chapter {chapter_data['chapter_num']}: '{chapter_album_name}'") + + series_folder_name = clean_folder_name(chapter_album_name.split(' Chapter')[0]) + chapter_folder_name = clean_folder_name(chapter_album_name) + final_save_path = os.path.join(self.output_dir, series_folder_name, chapter_folder_name) + os.makedirs(final_save_path, exist_ok=True) + + for file_data in chapter_data['files']: + if self._check_pause(): break + images_processed += 1 + + filename = file_data.get('filename') + filepath = os.path.join(final_save_path, filename) + + if os.path.exists(filepath): + self.progress_signal.emit(f" -> Skip ({images_processed}/{total_images}): '{filename}' already exists.") + grand_total_skip += 1 + continue + + self.progress_signal.emit(f" Downloading ({images_processed}/{total_images}): '{filename}'...") + + download_successful = False + for attempt in range(3): + if self._check_pause(): break + try: + headers = {'Referer': chapter_data['chapter_url']} + response = session.get(file_data.get('url'), stream=True, timeout=60, headers=headers) + response.raise_for_status() + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if self._check_pause(): break + f.write(chunk) + if not self._check_pause(): + download_successful = True + break + except (requests.exceptions.RequestException, ConnectionResetError): + if attempt < 2: time.sleep(2 * (attempt + 1)) + + if self._check_pause(): break + if download_successful: + grand_total_dl += 1 + else: + self.progress_signal.emit(f" ❌ Download failed for '{filename}' after 3 attempts. Skipping.") + if os.path.exists(filepath): os.remove(filepath) + grand_total_skip += 1 + self.overall_progress_signal.emit(total_images, images_processed) + time.sleep(random.uniform(0.2, 0.7)) + + if not is_single_chapter_mode: + time.sleep(random.uniform(1.5, 4.0)) + + self.file_progress_signal.emit("", None) + self.finished_signal.emit(grand_total_dl, grand_total_skip, self.is_cancelled) + + def cancel(self): + self.is_cancelled = True + self.progress_signal.emit(" Cancellation signal received by Hentai2Read thread.") + class ExternalLinkDownloadThread (QThread ): """A QThread to handle downloading multiple external links sequentially.""" progress_signal =pyqtSignal (str ) diff --git a/src/utils/network_utils.py b/src/utils/network_utils.py index bf85d9b..76b17ec 100644 --- a/src/utils/network_utils.py +++ b/src/utils/network_utils.py @@ -138,22 +138,10 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo return None -# In src/utils/network_utils.py - def extract_post_info(url_string): """ Parses a URL string to extract the service, user ID, and post ID. - UPDATED to support Discord, Bunkr, and nhentai URLs. - - Args: - url_string (str): The URL to parse. - - Returns: - tuple: A tuple containing (service, id1, id2). - For posts: (service, user_id, post_id). - For Discord: ('discord', server_id, channel_id). - For Bunkr: ('bunkr', full_url, None). - For nhentai: ('nhentai', gallery_id, None). + UPDATED to support Hentai2Read series and chapters. """ if not isinstance(url_string, str) or not url_string.strip(): return None, None, None @@ -171,6 +159,18 @@ def extract_post_info(url_string): nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url) if nhentai_match: return 'nhentai', nhentai_match.group(1), None + + # --- Hentai2Read Check (Updated) --- + # This regex now captures the manga slug (id1) and optionally the chapter number (id2) + hentai2read_match = re.search(r'hentai2read\.com/([^/]+)(?:/(\d+))?/?', stripped_url) + if hentai2read_match: + manga_slug, chapter_num = hentai2read_match.groups() + return 'hentai2read', manga_slug, chapter_num # chapter_num will be None for series URLs + + discord_channel_match = re.search(r'discord\.com/channels/(@me|\d+)/(\d+)', stripped_url) + if discord_channel_match: + server_id, channel_id = discord_channel_match.groups() + return 'discord', server_id, channel_id # --- Kemono/Coomer/Discord Parsing --- try: diff --git a/src/utils/resolution.py b/src/utils/resolution.py index 85925e5..8d5963f 100644 --- a/src/utils/resolution.py +++ b/src/utils/resolution.py @@ -284,7 +284,7 @@ def setup_ui(main_app): advanced_row2_layout.addLayout(multithreading_layout) main_app.external_links_checkbox = QCheckBox("Show External Links in Log") advanced_row2_layout.addWidget(main_app.external_links_checkbox) - main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode") + main_app.manga_mode_checkbox = QCheckBox("Renaming Mode") advanced_row2_layout.addWidget(main_app.manga_mode_checkbox) advanced_row2_layout.addStretch(1) checkboxes_group_layout.addLayout(advanced_row2_layout) @@ -391,10 +391,23 @@ def setup_ui(main_app): main_app.link_search_button.setVisible(False) main_app.link_search_button.setFixedWidth(int(30 * scale)) log_title_layout.addWidget(main_app.link_search_button) + + discord_controls_layout = QHBoxLayout() + main_app.discord_scope_toggle_button = QPushButton("Scope: Files") main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default - main_app.discord_scope_toggle_button.setFixedWidth(int(140 * scale)) - log_title_layout.addWidget(main_app.discord_scope_toggle_button) + discord_controls_layout.addWidget(main_app.discord_scope_toggle_button) + + main_app.discord_message_limit_input = QLineEdit(main_app) + main_app.discord_message_limit_input.setPlaceholderText("Msg Limit") + main_app.discord_message_limit_input.setToolTip("Optional: Limit the number of recent messages to process.") + main_app.discord_message_limit_input.setValidator(QIntValidator(1, 9999999, main_app)) + main_app.discord_message_limit_input.setFixedWidth(int(80 * scale)) + main_app.discord_message_limit_input.setVisible(False) # Hide it by default + discord_controls_layout.addWidget(main_app.discord_message_limit_input) + + log_title_layout.addLayout(discord_controls_layout) + main_app.manga_rename_toggle_button = QPushButton() main_app.manga_rename_toggle_button.setVisible(False) main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))