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_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 ---
@ -61,6 +60,10 @@ RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
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 = "<!HTML!>"

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,
'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:

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_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)

View File

@ -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.")
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")

View File

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

View File

@ -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
@ -3190,20 +3335,41 @@ 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 <b>Manga/Comic Mode</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"
"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 <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"
"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):
@ -5206,12 +5452,73 @@ class DownloaderApp (QWidget ):
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 )

View File

@ -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
@ -172,6 +160,18 @@ def extract_post_info(url_string):
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:
parsed_url = urlparse(stripped_url)

View File

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