This commit is contained in:
Yuvi9587 2025-09-07 04:56:08 -07:00
parent 24880b5042
commit 7217bfdb39
10 changed files with 1023 additions and 143 deletions

View File

@ -1,4 +1,3 @@
# --- Application Metadata ---
CONFIG_ORGANIZATION_NAME = "KemonoDownloader" CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
CONFIG_APP_NAME_MAIN = "ApplicationSettings" CONFIG_APP_NAME_MAIN = "ApplicationSettings"
CONFIG_APP_NAME_TOUR = "ApplicationTour" CONFIG_APP_NAME_TOUR = "ApplicationTour"
@ -9,7 +8,7 @@ STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based" STYLE_DATE_BASED = "date_based"
STYLE_DATE_POST_TITLE = "date_post_title" STYLE_DATE_POST_TITLE = "date_post_title"
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" 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 = "" MANGA_DATE_PREFIX_DEFAULT = ""
# --- Download Scopes --- # --- Download Scopes ---
@ -60,7 +59,11 @@ DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution" RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor" UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile" 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 --- # --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>" HTML_PREFIX = "<!HTML!>"
@ -120,4 +123,4 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
# --- Duplicate Handling Modes --- # --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash" DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all" DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

View File

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

View File

@ -848,6 +848,8 @@ class PostProcessorWorker:
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title, '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, '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, '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 return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
finally: finally:

File diff suppressed because one or more lines are too long

View File

@ -106,7 +106,17 @@ class ErrorFilesDialog(QDialog):
post_title = error_info.get('post_title', 'Unknown Post') post_title = error_info.get('post_title', 'Unknown Post')
post_id = error_info.get('original_post_id_for_log', 'N/A') 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 = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, error_info) list_item.setData(Qt.UserRole, error_info)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable) list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)

View File

@ -4,24 +4,109 @@ import json
import sys import sys
# --- PyQt5 Imports --- # --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
) )
# --- Local Application Imports --- # --- Local Application Imports ---
from ...i18n.translator import get_translation from ...i18n.translator import get_translation
from ...utils.resolution import get_dark_theme from ...utils.resolution import get_dark_theme
from ..assets import get_app_icon_object
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...config.constants import ( from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY, THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY, RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_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 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): class FutureSettingsDialog(QDialog):
""" """
A dialog for managing application-wide settings like theme, language, 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 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0 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_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
@ -55,7 +140,6 @@ class FutureSettingsDialog(QDialog):
self.interface_group_box = QGroupBox() self.interface_group_box = QGroupBox()
interface_layout = QGridLayout(self.interface_group_box) interface_layout = QGridLayout(self.interface_group_box)
# Theme, UI Scale, Language (unchanged)...
self.theme_label = QLabel() self.theme_label = QLabel()
self.theme_toggle_button = QPushButton() self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme) self.theme_toggle_button.clicked.connect(self._toggle_theme)
@ -87,21 +171,26 @@ class FutureSettingsDialog(QDialog):
self.default_path_label = QLabel() self.default_path_label = QLabel()
self.save_path_button = QPushButton() 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.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1) 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 = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed) 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 = QCheckBox()
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed) 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) main_layout.addWidget(self.download_window_group_box)
# --- NEW: Update Section ---
self.update_group_box = QGroupBox() self.update_group_box = QGroupBox()
update_layout = QGridLayout(self.update_group_box) update_layout = QGridLayout(self.update_group_box)
self.version_label = QLabel() self.version_label = QLabel()
@ -112,7 +201,6 @@ class FutureSettingsDialog(QDialog):
update_layout.addWidget(self.update_status_label, 0, 1) update_layout.addWidget(self.update_status_label, 0, 1)
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2) update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
main_layout.addWidget(self.update_group_box) main_layout.addWidget(self.update_group_box)
# --- END: New Section ---
main_layout.addStretch(1) main_layout.addStretch(1)
@ -129,28 +217,27 @@ class FutureSettingsDialog(QDialog):
self.language_label.setText(self._tr("language_label", "Language:")) self.language_label.setText(self._tr("language_label", "Language:"))
self.window_size_label.setText(self._tr("window_size_label", "Window Size:")) 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.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.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.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.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._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.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
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.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")) 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")) self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
current_version = self.parent_app.windowTitle().split(' v')[-1] 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.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.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")) self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
# --- END: New Translations ---
self._populate_display_combo_boxes() self._populate_display_combo_boxes()
self._populate_language_combo_box() self._populate_language_combo_box()
self._populate_post_download_action_combo()
self._load_checkbox_states() self._load_checkbox_states()
def _check_for_updates(self): def _check_for_updates(self):
"""Starts the update check thread."""
self.check_update_button.setEnabled(False) self.check_update_button.setEnabled(False)
self.update_status_label.setText(self._tr("update_status_checking", "Checking...")) self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
current_version = self.parent_app.windowTitle().split(' v')[-1] current_version = self.parent_app.windowTitle().split(' v')[-1]
@ -189,7 +276,6 @@ class FutureSettingsDialog(QDialog):
self.check_update_button.setEnabled(True) self.check_update_button.setEnabled(True)
self.ok_button.setEnabled(True) self.ok_button.setEnabled(True)
# --- (The rest of the file remains unchanged from your provided code) ---
def _load_checkbox_states(self): def _load_checkbox_states(self):
self.save_creator_json_checkbox.blockSignals(True) self.save_creator_json_checkbox.blockSignals(True)
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) 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.blockSignals(True)
self.ui_scale_combo_box.clear() self.ui_scale_combo_box.clear()
scales = [ scales = [
(0.5, "50%"), (0.5, "50%"), (0.7, "70%"), (0.9, "90%"), (1.0, "100% (Default)"),
(0.7, "70%"), (1.25, "125%"), (1.50, "150%"), (1.75, "175%"), (2.0, "200%")
(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) current_scale = self.parent_app.settings.value(UI_SCALE_KEY, 1.0)
for scale_val, scale_name in scales: for scale_val, scale_name in scales:
self.ui_scale_combo_box.addItem(scale_name, scale_val) 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)"), ("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"), ("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)") ("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
] ]
current_lang = self.parent_app.current_selected_language current_lang = self.parent_app.current_selected_language
for lang_code, lang_name in languages: for lang_code, lang_name in languages:
self.language_combo_box.addItem(lang_name, lang_code) 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"), QMessageBox.information(self, self._tr("language_change_title", "Language Changed"),
self._tr("language_change_message", "A restart is required...")) 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 path_saved = False
cookie_saved = False cookie_saved = False
token_saved = False
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input: if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip() current_path = self.parent_app.dir_input.text().strip()
if current_path and os.path.isdir(current_path): if current_path and os.path.isdir(current_path):
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path) self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
path_saved = True path_saved = True
if hasattr(self.parent_app, 'use_cookie_checkbox'): if hasattr(self.parent_app, 'use_cookie_checkbox'):
use_cookie = self.parent_app.use_cookie_checkbox.isChecked() use_cookie = self.parent_app.use_cookie_checkbox.isChecked()
cookie_content = self.parent_app.cookie_text_input.text().strip() cookie_content = self.parent_app.cookie_text_input.text().strip()
@ -323,8 +433,20 @@ class FutureSettingsDialog(QDialog):
else: else:
self.parent_app.settings.setValue(USE_COOKIE_KEY, False) self.parent_app.settings.setValue(USE_COOKIE_KEY, False)
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "") 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() 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: else:
QMessageBox.warning(self, "Nothing to Save", "No valid settings to save.") QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")

View File

@ -1,6 +1,7 @@
import os import os
import re import re
import datetime import datetime
import time
try: try:
from fpdf import FPDF from fpdf import FPDF
FPDF_AVAILABLE = True FPDF_AVAILABLE = True
@ -29,7 +30,7 @@ except ImportError:
FPDF = None FPDF = None
PDF = 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. 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. 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.") logger(" No messages were found or fetched to create a PDF.")
return False 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)...") 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) pdf = PDF(server_name, channel_name)
default_font_family = 'DejaVu' 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...") logger(f" Starting PDF creation with {len(messages_data)} messages...")
for i, message in enumerate(messages_data): 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') 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', '') content = message.get('content', '')
attachments = message.get('attachments', []) attachments = message.get('attachments', [])
embeds = message.get('embeds', []) embeds = message.get('embeds', [])
try: try:
# Handle timezone information correctly
if timestamp_str.endswith('Z'): if timestamp_str.endswith('Z'):
timestamp_str = timestamp_str[:-1] + '+00:00' timestamp_str = timestamp_str[:-1] + '+00:00'
dt_obj = datetime.datetime.fromisoformat(timestamp_str) 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): except (ValueError, TypeError):
formatted_timestamp = timestamp_str formatted_timestamp = timestamp_str
# Draw a separator line
if i > 0: if i > 0:
pdf.ln(2) 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.cell(0, 0, '', border='T')
pdf.ln(2) pdf.ln(2)
# Message Header
pdf.set_font(default_font_family, 'B', 11) pdf.set_font(default_font_family, 'B', 11)
pdf.write(5, f"{author} ") pdf.write(5, f"{author} ")
pdf.set_font(default_font_family, '', 9) 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.set_text_color(0, 0, 0)
pdf.ln(6) pdf.ln(6)
# Message Content
if content: if content:
pdf.set_font(default_font_family, '', 10) pdf.set_font(default_font_family, '', 10)
pdf.multi_cell(w=0, h=5, text=content) pdf.multi_cell(w=0, h=5, text=content)
# --- START: MODIFIED ATTACHMENT AND EMBED LOGIC ---
if attachments or embeds: if attachments or embeds:
pdf.ln(1) pdf.ln(1)
pdf.set_font(default_font_family, '', 9) 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: for att in attachments:
file_name = att.get('name', 'untitled') file_name = att.get('filename', 'untitled')
file_path = att.get('path', '') full_url = att.get('url', '#')
# Construct the full, clickable URL for the attachment
full_url = f"https://kemono.cr/data{file_path}"
pdf.write(5, text=f"[Attachment: {file_name}]", link=full_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: for embed in embeds:
embed_url = embed.get('url', 'no url') 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.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 pdf.set_text_color(0, 0, 0)
# --- END: MODIFIED ATTACHMENT AND EMBED LOGIC ---
if check_events(cancellation_event, pause_event):
logger(" PDF generation cancelled by user before final save.")
return False
try: try:
pdf.output(output_filename) pdf.output(output_filename)

View File

@ -2,6 +2,7 @@ import sys
import os import os
import time import time
import queue import queue
import random
import traceback import traceback
import html import html
import http import http
@ -41,6 +42,7 @@ from ..core.nhentai_client import fetch_nhentai_gallery
from ..core.bunkr_client import fetch_bunkr_data from ..core.bunkr_client import fetch_bunkr_data
from ..core.saint2_client import fetch_saint2_data from ..core.saint2_client import fetch_saint2_data
from ..core.erome_client import fetch_erome_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 .assets import get_app_icon_object
from ..config.constants import * from ..config.constants import *
from ..utils.file_utils import KNOWN_NAMES, clean_folder_name 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.FavoriteArtistsDialog import FavoriteArtistsDialog
from .dialogs.KnownNamesFilterDialog import KnownNamesFilterDialog from .dialogs.KnownNamesFilterDialog import KnownNamesFilterDialog
from .dialogs.HelpGuideDialog import HelpGuideDialog from .dialogs.HelpGuideDialog import HelpGuideDialog
from .dialogs.FutureSettingsDialog import FutureSettingsDialog from .dialogs.FutureSettingsDialog import FutureSettingsDialog, CountdownMessageBox
from .dialogs.ErrorFilesDialog import ErrorFilesDialog from .dialogs.ErrorFilesDialog import ErrorFilesDialog
from .dialogs.DownloadHistoryDialog import DownloadHistoryDialog from .dialogs.DownloadHistoryDialog import DownloadHistoryDialog
from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog
@ -67,6 +69,10 @@ from .dialogs.SupportDialog import SupportDialog
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
from .dialogs.MultipartScopeDialog import MultipartScopeDialog 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: class DynamicFilterHolder:
"""A thread-safe class to hold and update character filters during a download.""" """A thread-safe class to hold and update character filters during a download."""
def __init__(self, initial_filters=None): def __init__(self, initial_filters=None):
@ -286,7 +292,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None self.download_location_label_widget = None
self.remove_from_filename_label_widget = None self.remove_from_filename_label_widget = None
self.skip_words_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) setup_ui(self)
self._connect_signals() self._connect_signals()
if hasattr(self, 'character_input'): if hasattr(self, 'character_input'):
@ -305,6 +311,127 @@ class DownloaderApp (QWidget ):
self._check_for_interrupted_session() self._check_for_interrupted_session()
self._cleanup_after_update() 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): def _cleanup_after_update(self):
"""Deletes the old executable after a successful update.""" """Deletes the old executable after a successful update."""
try: 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_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 ,'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 ,'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 ,'thread_count_label'):self .thread_count_label .setText (self ._tr ("threads_label","Threads:"))
if hasattr (self ,'character_input'): if hasattr (self ,'character_input'):
@ -1202,64 +1329,83 @@ class DownloaderApp (QWidget ):
) )
pdf_thread.start() 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): def queue_logger(message):
self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)}) self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)})
def queue_progress_label_update(message): def queue_progress_label_update(message):
self.worker_to_gui_queue.put({'type': 'set_progress_label', 'payload': (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) self.set_ui_enabled(False)
queue_logger("=" * 40) queue_logger("=" * 40)
queue_logger(f"🚀 Starting Discord PDF export for: {api_url}") queue_logger(f"🚀 Starting Discord PDF export for: {api_url}")
queue_progress_label_update("Fetching messages...") queue_progress_label_update("Fetching messages...")
all_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 = [] channels_to_process = []
server_name_for_pdf = server_id server_name_for_pdf = server_id
if channel_id: if channel_id:
channels_to_process.append({'id': channel_id, 'name': channel_id}) channels_to_process.append({'id': channel_id, 'name': channel_id})
else: else:
channels = fetch_server_channels(server_id, queue_logger, cookies) # Use safe logger # This logic can be expanded later to fetch all channels in a server if needed
if channels: pass
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
# Fetch messages for all required channels
for i, channel in enumerate(channels_to_process): 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', '')}") 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 last_message_id = None
for message_batch in message_generator: 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) 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...") 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'): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS base_path = sys._MEIPASS
else: else:
base_path = self.app_base_dir base_path = self.app_base_dir
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf') font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
# Generate the PDF
success = create_pdf_from_discord_messages( success = create_pdf_from_discord_messages(
all_messages, all_messages,
server_name_for_pdf, server_name_for_pdf,
channels_to_process[0].get('name', channel_id) if len(channels_to_process) == 1 else "All Channels", channels_to_process[0].get('name', channel_id) if len(channels_to_process) == 1 else "All Channels",
output_filepath, output_filepath,
font_path, font_path,
logger=queue_logger # Use safe logger logger=queue_logger
) )
if success: if success:
@ -1267,9 +1413,7 @@ class DownloaderApp (QWidget ):
else: else:
queue_progress_label_update(f"❌ PDF export failed. Check log for details.") queue_progress_label_update(f"❌ PDF export failed. Check log for details.")
queue_logger("=" * 40) self.finished_signal.emit(0, len(all_messages), self.cancellation_event.is_set(), [])
# Safely re-enable the UI from the main thread via the queue
self.worker_to_gui_queue.put({'type': 'set_ui_enabled', 'payload': (True,)})
def save_known_names(self): 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.use_cookie_checkbox, self.keep_duplicates_checkbox, self.date_prefix_checkbox,
self.manga_rename_toggle_button, self.manga_date_prefix_input, self.manga_rename_toggle_button, self.manga_date_prefix_input,
self.multipart_toggle_button, self.custom_folder_input, self.custom_folder_label, 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 enable_state = not is_specialized
@ -3189,21 +3334,42 @@ class DownloaderApp (QWidget ):
url_text = self.link_input.text().strip() url_text = self.link_input.text().strip()
service, _, _ = extract_post_info(url_text) 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_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text
is_erome = 'erome.com' 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) self._set_ui_for_specialized_downloader(is_specialized)
# Handle Discord UI # --- FIX: Show the Scope button for ANY Discord URL (Kemono or official) ---
is_discord = (service == 'discord') self.discord_scope_toggle_button.setVisible(is_any_discord_url)
self.discord_scope_toggle_button.setVisible(is_discord) if hasattr(self, 'discord_message_limit_input'):
self.save_discord_as_pdf_btn.setVisible(is_discord) # 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() 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")) self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
def _update_discord_scope_button_text(self): def _update_discord_scope_button_text(self):
@ -3475,6 +3641,72 @@ class DownloaderApp (QWidget ):
service, id1, id2 = extract_post_info(api_url) 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': if service == 'nhentai':
gallery_id = id1 gallery_id = id1
self.log_signal.emit("=" * 40) self.log_signal.emit("=" * 40)
@ -3874,11 +4106,11 @@ class DownloaderApp (QWidget ):
msg_box.setIcon(QMessageBox.Warning) msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Manga Mode & Page Range Warning") msg_box.setWindowTitle("Manga Mode & Page Range Warning")
msg_box.setText( msg_box.setText(
"You have enabled <b>Manga/Comic Mode</b> and also specified a <b>Page Range</b>.\n\n" "You have enabled <b>Renaming Mode</b> with a sequential naming style (<b>Date Based</b> or <b>Title + G.Num</b>) and also specified a <b>Page Range</b>.\n\n"
"Manga Mode processes posts from oldest to newest across all available pages by default.\n" "These modes rely on processing all posts from the beginning to create a correct sequence. "
"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" "Using a page range may result in an incomplete or incorrectly ordered download.\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" "It is recommended to use these styles without a page range.\n\n"
"Do you want to proceed with this page range in Manga Mode?" "Do you want to proceed anyway?"
) )
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole) cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
@ -4345,7 +4577,8 @@ class DownloaderApp (QWidget ):
self.discord_scope_toggle_button.setVisible(is_discord) self.discord_scope_toggle_button.setVisible(is_discord)
if hasattr(self, 'save_discord_as_pdf_btn'): if hasattr(self, 'save_discord_as_pdf_btn'):
self.save_discord_as_pdf_btn.setVisible(is_discord) 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: if is_discord:
self._update_discord_scope_button_text() self._update_discord_scope_button_text()
else: else:
@ -4909,16 +5142,29 @@ class DownloaderApp (QWidget ):
self .update_ui_for_subfolders (subfolders_currently_on ) self .update_ui_for_subfolders (subfolders_currently_on )
self ._handle_favorite_mode_toggle (is_fav_mode_active ) self ._handle_favorite_mode_toggle (is_fav_mode_active )
def _handle_pause_resume_action (self ): def _handle_pause_resume_action(self):
if self ._is_download_active (): # --- FIX: Simplified and corrected the pause/resume logic ---
self .is_paused =not self .is_paused if not self._is_download_active():
if self .is_paused : return
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.") # Toggle the main app's pause state tracker
else : self.is_paused = not self.is_paused
if self .pause_event :self .pause_event .clear ()
self .log_signal .emit (" Download resumed by user.") # Call the correct method on the thread based on the new state
self .set_ui_enabled (False ) 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 ): 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.""" """Resets UI elements and some state to app defaults, then applies preserved inputs."""
@ -5016,16 +5262,12 @@ class DownloaderApp (QWidget ):
self ._filter_links_log () self ._filter_links_log ()
def cancel_download_button_action(self): def cancel_download_button_action(self):
""" if self._is_download_active() and hasattr(self.download_thread, 'cancel'):
Signals all active download processes to cancel but DOES NOT reset the UI. self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
The UI reset is now handled by the 'download_finished' method. self.download_thread.cancel()
""" else:
if self.cancellation_event.is_set(): # Fallback for other download types
self.log_signal.emit(" Cancellation is already in progress.") self.cancellation_event.set()
return
self.log_signal.emit("⚠️ Requesting cancellation of download process...")
self.cancellation_event.set()
# Update UI to "Cancelling" state # Update UI to "Cancelling" state
self.pause_btn.setEnabled(False) self.pause_btn.setEnabled(False)
@ -5064,6 +5306,10 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" Signaling Erome download thread to cancel.") self.log_signal.emit(" Signaling Erome download thread to cancel.")
self.download_thread.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: def _get_domain_for_service(self, service_name: str) -> str:
"""Determines the base domain for a given service.""" """Determines the base domain for a given service."""
if not isinstance(service_name, str): if not isinstance(service_name, str):
@ -5205,13 +5451,74 @@ class DownloaderApp (QWidget ):
self.retryable_failed_files_info.clear() self.retryable_failed_files_info.clear()
self.is_fetcher_thread_running = False 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.set_ui_enabled(True)
self._update_button_states_and_connections() self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
self.active_update_profile = None self.active_update_profile = None
finally: 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): def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is 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. # Use a QTimer to avoid deep recursion and correctly move to the next item.
QTimer.singleShot(100, self._process_next_favorite_download) 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): class Saint2DownloadThread(QThread):
"""A dedicated QThread for handling saint2.su downloads.""" """A dedicated QThread for handling saint2.su downloads."""
progress_signal = pyqtSignal(str) progress_signal = pyqtSignal(str)
@ -6497,6 +6988,159 @@ class BunkrDownloadThread(QThread):
self.is_cancelled = True self.is_cancelled = True
self.progress_signal.emit(" Cancellation signal received by Bunkr thread.") 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 ): class ExternalLinkDownloadThread (QThread ):
"""A QThread to handle downloading multiple external links sequentially.""" """A QThread to handle downloading multiple external links sequentially."""
progress_signal =pyqtSignal (str ) progress_signal =pyqtSignal (str )

View File

@ -138,22 +138,10 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
return None return None
# In src/utils/network_utils.py
def extract_post_info(url_string): def extract_post_info(url_string):
""" """
Parses a URL string to extract the service, user ID, and post ID. Parses a URL string to extract the service, user ID, and post ID.
UPDATED to support Discord, Bunkr, and nhentai URLs. UPDATED to support Hentai2Read series and chapters.
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).
""" """
if not isinstance(url_string, str) or not url_string.strip(): if not isinstance(url_string, str) or not url_string.strip():
return None, None, None 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) nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url)
if nhentai_match: if nhentai_match:
return 'nhentai', nhentai_match.group(1), None 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 --- # --- Kemono/Coomer/Discord Parsing ---
try: try:

View File

@ -284,7 +284,7 @@ def setup_ui(main_app):
advanced_row2_layout.addLayout(multithreading_layout) advanced_row2_layout.addLayout(multithreading_layout)
main_app.external_links_checkbox = QCheckBox("Show External Links in Log") main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
advanced_row2_layout.addWidget(main_app.external_links_checkbox) 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.addWidget(main_app.manga_mode_checkbox)
advanced_row2_layout.addStretch(1) advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout) 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.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale)) main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button) 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 = QPushButton("Scope: Files")
main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default
main_app.discord_scope_toggle_button.setFixedWidth(int(140 * scale)) discord_controls_layout.addWidget(main_app.discord_scope_toggle_button)
log_title_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 = QPushButton()
main_app.manga_rename_toggle_button.setVisible(False) main_app.manga_rename_toggle_button.setVisible(False)
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale)) main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))