From 7217bfdb39e89e13997ef0592d2d679e012aac40 Mon Sep 17 00:00:00 2001
From: Yuvi9587 <114073886+Yuvi9587@users.noreply.github.com>
Date: Sun, 7 Sep 2025 04:56:08 -0700
Subject: [PATCH] Commit
---
src/config/constants.py | 11 +-
src/core/Hentai2read_client.py | 72 +++
src/core/workers.py | 2 +
src/i18n/translator.py | 16 +-
src/ui/dialogs/ErrorFilesDialog.py | 12 +-
src/ui/dialogs/FutureSettingsDialog.py | 184 +++++-
src/ui/dialogs/discord_pdf_generator.py | 52 +-
src/ui/main_window.py | 772 ++++++++++++++++++++++--
src/utils/network_utils.py | 26 +-
src/utils/resolution.py | 19 +-
10 files changed, 1023 insertions(+), 143 deletions(-)
create mode 100644 src/core/Hentai2read_client.py
diff --git a/src/config/constants.py b/src/config/constants.py
index d317f5f..edbfc2a 100644
--- a/src/config/constants.py
+++ b/src/config/constants.py
@@ -1,4 +1,3 @@
-# --- Application Metadata ---
CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
CONFIG_APP_NAME_MAIN = "ApplicationSettings"
CONFIG_APP_NAME_TOUR = "ApplicationTour"
@@ -9,7 +8,7 @@ STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based"
STYLE_DATE_POST_TITLE = "date_post_title"
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
-STYLE_POST_ID = "post_id" # Add this line
+STYLE_POST_ID = "post_id"
MANGA_DATE_PREFIX_DEFAULT = ""
# --- Download Scopes ---
@@ -60,7 +59,11 @@ DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
-FETCH_FIRST_KEY = "fetchAllPostsFirst"
+FETCH_FIRST_KEY = "fetchAllPostsFirst"
+# --- FIX: Add the missing key for the Discord token ---
+DISCORD_TOKEN_KEY = "discord/token"
+
+POST_DOWNLOAD_ACTION_KEY = "postDownloadAction"
# --- UI Constants and Identifiers ---
HTML_PREFIX = ""
@@ -120,4 +123,4 @@ CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
-DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
\ No newline at end of file
+DUPLICATE_HANDLING_KEEP_ALL = "keep_all"
\ No newline at end of file
diff --git a/src/core/Hentai2read_client.py b/src/core/Hentai2read_client.py
new file mode 100644
index 0000000..38bbac5
--- /dev/null
+++ b/src/core/Hentai2read_client.py
@@ -0,0 +1,72 @@
+# src/core/Hentai2read_client.py
+
+import re
+import os
+import json
+import requests
+import cloudscraper
+from bs4 import BeautifulSoup
+
+def fetch_hentai2read_data(url, logger, session):
+ """
+ Scrapes a SINGLE Hentai2Read chapter page using a provided session.
+ """
+ logger(f"Attempting to fetch chapter data from: {url}")
+
+ try:
+ response = session.get(url, timeout=30)
+ response.raise_for_status()
+
+ page_content_text = response.text
+ soup = BeautifulSoup(page_content_text, 'html.parser')
+
+ album_title = ""
+ title_tags = soup.select('span[itemprop="name"]')
+ if title_tags:
+ album_title = title_tags[-1].text.strip()
+
+ if not album_title:
+ title_tag = soup.select_one('h1.title')
+ if title_tag:
+ album_title = title_tag.text.strip()
+
+ if not album_title:
+ logger("❌ Could not find album title on page.")
+ return None, None
+
+ image_urls = []
+ try:
+ start_index = page_content_text.index("'images' : ") + len("'images' : ")
+ end_index = page_content_text.index(",\n", start_index)
+ images_json_str = page_content_text[start_index:end_index]
+ image_paths = json.loads(images_json_str)
+ image_urls = ["https://hentaicdn.com/hentai" + part for part in image_paths]
+ except (ValueError, json.JSONDecodeError):
+ logger("❌ Could not find or parse image JSON data for this chapter.")
+ return None, None
+
+ if not image_urls:
+ logger("❌ No image URLs found for this chapter.")
+ return None, None
+
+ logger(f" Found {len(image_urls)} images for album '{album_title}'.")
+
+ files_to_download = []
+ for i, img_url in enumerate(image_urls):
+ page_num = i + 1
+ extension = os.path.splitext(img_url)[1].split('?')[0]
+ if not extension: extension = ".jpg"
+ filename = f"{page_num:03d}{extension}"
+ files_to_download.append({'url': img_url, 'filename': filename})
+
+ return album_title, files_to_download
+
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code == 404:
+ logger(f" Chapter not found (404 Error). This likely marks the end of the series.")
+ else:
+ logger(f"❌ An HTTP error occurred: {e}")
+ return None, None
+ except Exception as e:
+ logger(f"❌ An unexpected error occurred while fetching data: {e}")
+ return None, None
diff --git a/src/core/workers.py b/src/core/workers.py
index f7c9b82..c863f1b 100644
--- a/src/core/workers.py
+++ b/src/core/workers.py
@@ -848,6 +848,8 @@ class PostProcessorWorker:
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post,
'forced_filename_override': filename_to_save_in_main_path,
+ 'service': self.service,
+ 'user_id': self.user_id
}
return 0, 1, final_filename_saved_for_return, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, permanent_failure_details
finally:
diff --git a/src/i18n/translator.py b/src/i18n/translator.py
index b08ebfc..2347b92 100644
--- a/src/i18n/translator.py
+++ b/src/i18n/translator.py
@@ -2664,7 +2664,7 @@ translations ["en"]={
"use_cookie_checkbox_label": "Use cookie",
"use_multithreading_checkbox_base_label": "Use multithreading",
"show_external_links_checkbox_label": "Show external links in log",
- "manga_comic_mode_checkbox_label": "Manga/Comic Mode",
+ "manga_comic_mode_checkbox_label": "Renaming Mode",
"threads_label": "Threads:",
"start_download_button_text": "⬇️ Start Download",
"start_download_button_tooltip": "Click to start the download or link extraction process with the current settings.",
@@ -2851,10 +2851,10 @@ translations ["en"]={
"cookie_browse_button_tooltip": "Browse for a cookie file (Netscape format, usually cookies.txt).\nThis will be used if 'Use cookie' is checked and the text field above is empty.",
"page_range_label_text": "Page Range:",
"start_page_input_placeholder": "Start",
- "start_page_input_tooltip": "For creator URLs: Specify the starting page number for the download (e.g., 1, 2, 3).\nLeave empty or set to 1 to start from the first page.\nDisabled for single post URLs or in Manga/Comic Mode.",
+ "start_page_input_tooltip": "For creator URLs: Specify the starting page number for the download (e.g., 1, 2, 3).\nLeave empty or set to 1 to start from the first page.\nDisabled for single post URLs or in Renaming Mode.",
"page_range_to_label_text": "to",
"end_page_input_placeholder": "End",
- "end_page_input_tooltip": "For creator URLs: Specify the ending page number for the download (e.g., 5, 10).\nLeave empty to download all pages from the start page.\nDisabled for single post URLs or in Manga/Comic Mode.",
+ "end_page_input_tooltip": "For creator URLs: Specify the ending page number for the download (e.g., 5, 10).\nLeave empty to download all pages from the start page.\nDisabled for single post URLs or in Renaming Mode.",
"known_names_help_button_tooltip_text": "Open the application feature guide.",
"future_settings_button_tooltip_text": "Open application settings (Theme, Language, etc.).",
"link_search_button_tooltip_text": "Filter displayed links",
@@ -2890,7 +2890,7 @@ translations ["en"]={
"tour_dialog_step1_title": "👋 Welcome!",
"tour_dialog_step1_content": "Hello! This quick tour will guide you through the main features of Kemono Downloader, including recent updates like enhanced filtering, manga mode improvements, and cookie handling.\n
\n- My goal is to help you easily download content from Kemono and Coomer.
\n- 🎨 Creator Selection Button: Next to the URL input, click the palette icon to open a dialog. Browse and select creators from your
creators.json file to quickly add their names to the URL input.
\n- Important Tip: App '(Not Responding)'?
\nAfter clicking 'Start Download', especially for large creator feeds or with many threads, the app might temporarily show '(Not Responding)'. Your operating system (Windows, macOS, Linux) might even suggest you 'End Process' or 'Force Quit'.
\nPlease be patient! The app is often working hard in the background. Before force-closing, try checking your chosen 'Download Location' in your file explorer. If you see new folders being created or files appearing, it means the download is progressing correctly. Give it some time to become responsive again.
\n- Use the Next and Back buttons to navigate.
\n- Many options have tooltips if you hover over them for more details.
\n- Click Skip Tour to close this guide at any time.
\n- Check 'Never show this tour again' if you don't want to see this on future startups.
\n
",
"tour_dialog_step2_title": "① Getting Started",
- "tour_dialog_step2_content": "Let's start with the download basics:\n\n- 🔗 Creator/Post Kemono URL:
\nPaste the full web address (URL) of a creator's page (e.g., https://kemono.su/patreon/user/12345) \nor a specific post (e.g., .../post/98765).
\nor a Coomer creator (e.g., https://coomer.su/onlyfans/user/artistname)
\n- 📁 Download Location:
\nClick 'Browse...' to choose a folder on your computer where all downloaded files will be saved. \nThis is required unless you are using 'Links Only' mode.
\n- 📄 Page Range (Creator URLs only):
\nIf downloading from a creator's page, you can specify a range of pages to grab (e.g., pages 2 to 5). \nLeave blank for all pages. This is disabled for single post URLs or when Manga/Comic Mode is active. \n
",
+ "tour_dialog_step2_content": "Let's start with the download basics:\n\n- 🔗 Creator/Post Kemono URL:
\nPaste the full web address (URL) of a creator's page (e.g., https://kemono.su/patreon/user/12345) \nor a specific post (e.g., .../post/98765).
\nor a Coomer creator (e.g., https://coomer.su/onlyfans/user/artistname)
\n- 📁 Download Location:
\nClick 'Browse...' to choose a folder on your computer where all downloaded files will be saved. \nThis is required unless you are using 'Links Only' mode.
\n- 📄 Page Range (Creator URLs only):
\nIf downloading from a creator's page, you can specify a range of pages to grab (e.g., pages 2 to 5). \nLeave blank for all pages. This is disabled for single post URLs or when Renaming Mode is active. \n
",
"tour_dialog_step3_title": "② Filtering Downloads",
"tour_dialog_step3_content": "Refine what you download with these filters (most are disabled in 'Links Only' or 'Archives Only' modes):\n\n- 🎯 Filter by Character(s):
\nEnter character names, separated by commas (e.g., Tifa, Aerith). Group aliases for a combined folder name: (alias1, alias2, alias3) becomes the folder 'alias1 alias2 alias3' (after cleanup). All names in the group are used as aliases for matching.
\nThe 'Filter: [Type]' button (next to this input) changes how this filter applies:\n- Filter: Files: Checks individual filenames. A post is kept if any file matches; only the matching files are downloaded. Folder naming uses the character from the matching filename (if 'Separate Folders' is on).
\n- Filter: Title: Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title.
\n- ⤵️ Add to Filter Button (Known Names): Next to the 'Add' button for Known Names (see Step 5), this opens a popup. Select names from your
Known.txt list via checkboxes (with a search bar) to quickly add them to the 'Filter by Character(s)' field. Grouped names like (Boa, Hancock) from Known.txt will be added as (Boa, Hancock)~ to the filter.
\n- Filter: Both: Checks the post title first. If it matches, all files are downloaded. If not, it then checks filenames, and only matching files are downloaded. Folder naming prioritizes the title match, then the file match.
\n- Filter: Comments (Beta): Checks filenames first. If a file matches, all files in the post are downloaded. If no file match, it then checks post comments. If a comment matches, all files are downloaded. (Uses more API requests). Folder naming prioritizes the file match, then the comment match.
\nThis filter also influences folder naming if 'Separate folders by Known.txt' is on.
\n- 🚫 Skip with words:
\nEnter words, comma-separated (e.g., WIP, sketch, preview). \nThe 'Scope: [Type]' button (next to this input) changes how this filter applies:\n- Scope: Files: Skips files if their names contain any of these words.
\n- Scope: Posts: Skips entire posts if their titles contain any of these words.
\n- Scope: Both: Applies both file and post title skipping (post first, then files).
\n- Filter Files (Radio Buttons): Choose what to download:\n
\n- All: Downloads all file types found.
\n- Images/GIFs: Only common image formats and GIFs.
\n- Videos: Only common video formats.
\n- 📦 Only Archives: Exclusively downloads Archives and .rar files. When this is selected, the 'Skip Archives' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show external links' is also disabled.
\n- 🎧 Only Audio: Only common audio formats (MP3, WAV, FLAC, etc.).
\n- 🔗 Only Links: Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show external links' are disabled.
\n
\n
",
"tour_dialog_step4_title": "③ Favorite Mode (Alternate Downloading)",
@@ -2898,7 +2898,7 @@ translations ["en"]={
"tour_dialog_step5_title": "④ Refining Downloads",
"tour_dialog_step5_content": "More options to customize your downloads:\n\n- Skip Archives / Skip .rar: Check these to avoid downloading these archive file types. \n(Note: These are disabled and ignored if '📦 Only Archives' filter mode is selected).
\n- ✂️ Remove words from name:
\nEnter words, comma-separated (e.g., patreon, [HD]), to be removed from downloaded filenames (case-insensitive).
\n- Download thumbnails only: Downloads the small preview images instead of full-size files (if available).
\n- Compress large images: If the 'Pillow' library is installed, images over 1.5MB will be converted to WebP format if the WebP version is significantly smaller.
\n- 🗄️ Custom Folder Name (Single Post Only):
\nIf you are downloading a specific post URL AND 'Separate folders by Known.txt' is enabled, \nyou can enter a custom name here for that post's download folder.
\n- 🍪 Use cookie: Check this to use cookies for requests. You can either:\n
- Enter a cookie string directly into the text field (e.g., name1=value1; name2=value2).
\n- Click 'Browse...' to select a cookies.txt file (Netscape format). The path will appear in the text field.
\nThis is useful for accessing content that requires a login. The text field takes priority if filled. \nIf 'Use cookie' is checked but both the text field and browsed file are empty, it will try to load 'cookies.txt' from the app's directory. \n
",
"tour_dialog_step6_title": "⑤ Organization & Performance",
- "tour_dialog_step6_content": "Organize your downloads and manage performance:\n\n- ⚙️ Separate folders by Known.txt: Creates subfolders based on the 'Filter by Character(s)' input or post titles (can use the Known.txt list as a fallback for folder names).
\n- Subfolder per post: If 'Separate Folders' is on, this creates an additional subfolder for each individual post inside the main character/title folder.
\n- 🚀 Use multithreading (Threads): Enables faster operations. The number in the 'Threads' input means:\n
- For Creator Feeds: Number of posts to process simultaneously. Files from each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).
\n- For Single Post URLs: Number of files to download simultaneously from that single post.
\nIf unchecked, 1 thread is used. High thread counts (e.g., >40) may show a warning.
\n- Multipart Download Toggle (top-right of log area):
\nThe 'Multi-part: [ON/OFF]' button enables/disables multi-segment downloads for individual large files. \n- ON: Can speed up large file downloads (e.g., videos) but may increase UI stutter or log spam with many small files. A warning will appear on activation. If a multipart download fails, it retries as a single stream.
\n- OFF (Default): Files are downloaded in a single stream.
\nThis is disabled if 'Links Only' or 'Archives Only' mode is active.
\n- 📖 Manga/Comic Mode (Creator URLs only): Designed for sequential content.\n
\n- Downloads posts from oldest to newest.
\n- The 'Page Range' input is disabled as all posts are fetched.
\n- A filename style toggle button (e.g., 'Name: Post Title') appears at the top-right of the log area when this mode is active for a creator feed. Click it to cycle between naming styles:\n
\n- Name: Post Title (Default): The first file in a post is named after the cleaned post title (e.g., 'My Chapter 1.jpg'). Subsequent files in the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics.
\n- Name: Original File: All files attempt to keep their original filenames. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_OriginalFile.jpg'.
\n- Name: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the cleaned post title as a prefix, followed by a global counter. E.g.: Post 'Chapter 1' (2 files) -> 'Chapter 1_001.jpg', 'Chapter 1_002.png'. The next post, 'Chapter 2' (1 file), would continue the numbering -> 'Chapter 2_003.jpg'. Multithreading for post processing is automatically disabled for this style to ensure correct global numbering.
\n- Name: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on the publish order of the posts. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style.
\n
\n
\n- For best results with the 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
\n
\n- 🎭 Known.txt for Smart Folder Organization:
\nKnown.txt (in the app directory) allows fine-grained control over automatic folder organization when 'Separate folders by Known.txt' is on.\n\n- How it works: Each line in
Known.txt is an entry. \n- A simple line like
My Awesome Series means matching content will go into a folder named \"My Awesome Series\".
\n- A grouped line like
(Character A, Char A, Alt Name A) means content matching \"Character A\", \"Char A\", OR \"Alt Name A\" will ALL go into a single folder named \"Character A Char A Alt Name A\" (after cleanup). All terms in the parentheses become aliases for that folder.
\n- Smart Fallback: When 'Separate folders by Known.txt' is on, and if a post doesn't match any specific 'Filter by Character(s)' entries, the downloader consults
Known.txt to find a matching master name for folder creation.
\n- User-Friendly Management: Add simple (non-grouped) names via the UI list below. For advanced editing (like creating/modifying grouped aliases), click 'Open Known.txt' to edit the file in your text editor. The app reloads it on next use or next startup.
\n
\n \n
",
+ "tour_dialog_step6_content": "Organize your downloads and manage performance:\n\n- ⚙️ Separate folders by Known.txt: Creates subfolders based on the 'Filter by Character(s)' input or post titles (can use the Known.txt list as a fallback for folder names).
\n- Subfolder per post: If 'Separate Folders' is on, this creates an additional subfolder for each individual post inside the main character/title folder.
\n- 🚀 Use multithreading (Threads): Enables faster operations. The number in the 'Threads' input means:\n
- For Creator Feeds: Number of posts to process simultaneously. Files from each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).
\n- For Single Post URLs: Number of files to download simultaneously from that single post.
\nIf unchecked, 1 thread is used. High thread counts (e.g., >40) may show a warning.
\n- Multipart Download Toggle (top-right of log area):
\nThe 'Multi-part: [ON/OFF]' button enables/disables multi-segment downloads for individual large files. \n- ON: Can speed up large file downloads (e.g., videos) but may increase UI stutter or log spam with many small files. A warning will appear on activation. If a multipart download fails, it retries as a single stream.
\n- OFF (Default): Files are downloaded in a single stream.
\nThis is disabled if 'Links Only' or 'Archives Only' mode is active.
\n- 📖 Renaming Mode (Creator URLs only): Designed for sequential content.\n
\n- Downloads posts from oldest to newest.
\n- The 'Page Range' input is disabled as all posts are fetched.
\n- A filename style toggle button (e.g., 'Name: Post Title') appears at the top-right of the log area when this mode is active for a creator feed. Click it to cycle between naming styles:\n
\n- Name: Post Title (Default): The first file in a post is named after the cleaned post title (e.g., 'My Chapter 1.jpg'). Subsequent files in the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics.
\n- Name: Original File: All files attempt to keep their original filenames. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_OriginalFile.jpg'.
\n- Name: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the cleaned post title as a prefix, followed by a global counter. E.g.: Post 'Chapter 1' (2 files) -> 'Chapter 1_001.jpg', 'Chapter 1_002.png'. The next post, 'Chapter 2' (1 file), would continue the numbering -> 'Chapter 2_003.jpg'. Multithreading for post processing is automatically disabled for this style to ensure correct global numbering.
\n- Name: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on the publish order of the posts. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style.
\n
\n
\n- For best results with the 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
\n
\n- 🎭 Known.txt for Smart Folder Organization:
\nKnown.txt (in the app directory) allows fine-grained control over automatic folder organization when 'Separate folders by Known.txt' is on.\n\n- How it works: Each line in
Known.txt is an entry. \n- A simple line like
My Awesome Series means matching content will go into a folder named \"My Awesome Series\".
\n- A grouped line like
(Character A, Char A, Alt Name A) means content matching \"Character A\", \"Char A\", OR \"Alt Name A\" will ALL go into a single folder named \"Character A Char A Alt Name A\" (after cleanup). All terms in the parentheses become aliases for that folder.
\n- Smart Fallback: When 'Separate folders by Known.txt' is on, and if a post doesn't match any specific 'Filter by Character(s)' entries, the downloader consults
Known.txt to find a matching master name for folder creation.
\n- User-Friendly Management: Add simple (non-grouped) names via the UI list below. For advanced editing (like creating/modifying grouped aliases), click 'Open Known.txt' to edit the file in your text editor. The app reloads it on next use or next startup.
\n
\n \n
",
"tour_dialog_step7_title": "⑥ Common Errors & Troubleshooting",
"tour_dialog_step7_content": "Sometimes downloads can run into issues. Here are some of the most common ones:\n\n- 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout:
\nThese usually indicate temporary server-side problems with Kemono/Coomer. The site might be overloaded, down for maintenance, or having issues.
\nSolution: Wait a while (e.g., 30 minutes to a few hours) and try again later. Check the site directly in your browser.
\n- Connection Lost / Connection Refused / Timeout (during file download):
\nThis can happen due to your internet connection, server instability, or if the server drops the connection for a large file.
\nSolution: Check your internet. Try reducing the 'Threads' count if it's high. The app may offer to retry some failed files at the end of a session.
\n- IncompleteRead Error:
\nThe server sent less data than expected. Often a temporary network hiccup or server issue.
\nSolution: The app will often mark these files for a retry at the end of the download session.
\n- 403 Forbidden / 401 Unauthorized (less common for public posts):
\nYou may not have permission to access the content. For some paywalled or private content, using the 'Use cookie' option with valid cookies from your browser session might help. Ensure your cookies are up to date.
\n- 404 Not Found:
\nThe post or file URL is incorrect, or the content has been deleted from the site. Double-check the URL.
\n- 'No posts found' / 'Target post not found':
\nEnsure the URL is correct and the creator/post exists. If using page ranges, make sure they are valid for the creator. For very new posts, there might be a slight delay before they appear in the API.
\n- General Slowness / App '(Not Responding)':
\nAs mentioned in Step 1, if the app appears to freeze after starting, especially with large creator feeds or many threads, please give it time. It is likely processing data in the background. Reducing the thread count can sometimes improve responsiveness if this is frequent. \n
",
"tour_dialog_step8_title": "⑦ Logs & Final Controls",
@@ -2908,7 +2908,7 @@ translations ["en"]={
"help_guide_instagram_tooltip": "Visit our Instagram page (Opens in browser)",
"help_guide_discord_tooltip": "Join our Discord community (Opens in browser)",
"help_guide_step1_title": "① Introduction & Main Inputs",
- "help_guide_step1_content": "\nThis guide provides an overview of the features, fields, and buttons in the Kemono Downloader.
\nMain Input Area (Top-Left)
\n\n- 🔗 Creator/Post Kemono URL:\n
\n- Enter the full web address of a creator's page (e.g., https://kemono.su/patreon/user/12345) or a specific post (e.g., .../post/98765).
\n- Supports Kemono (kemono.su, kemono.party) and Coomer (coomer.su, coomer.party) URLs.
\n
\n \n- Page Range (Start to End):\n
\n- For creator URLs: Specify a range of pages to grab (e.g., pages 2 to 5). Leave blank for all pages.
\n- Disabled for single post URLs or when Manga/Comic Mode is active.
\n
\n \n- 📁 Download Location:\n
\n- Click 'Browse...' to choose a main folder on your computer where all downloaded files will be saved.
\n- This field is required unless you are using '🔗 Only Links' mode.
\n
\n \n- 🎨 Creator Selection Button (next to URL input):\n
\n- Click the palette icon (🎨) to open the 'Creator Selection' dialog.
\n- This dialog loads creators from your
creators.json file (which must be in the app directory). \n- Inside the dialog:\n
\n- Search bar: Type to filter the creator list by name or service.
\n- Creator list: Displays creators from your
creators.json. Creators you have marked as 'favorites' (in the JSON data) appear at the top. \n- Checkboxes: Select one or more creators by checking the box next to their name.
\n- 'Scope' Button (e.g., 'Scope: Characters'): This button toggles the download organization when initiating downloads from this popup:\n
- Scope: Characters: Downloads will be organized into character-named folders directly in your main 'Download Location'. Art from different creators for the same character will be grouped.
\n- Scope: Creators: Downloads will first create a creator-named folder in your main 'Download Location'. Character-named subfolders will then be created inside each creator's folder.
\n \n- 'Add Selected' Button: Clicking this will take the names of all checked creators and add them to the main '🔗 Creator/Post Kemono URL' input field, separated by commas. The dialog will then close.
\n
\n \n- This feature provides a quick way to populate the URL field for multiple creators without manually typing or pasting each URL.
\n
\n \n
",
+ "help_guide_step1_content": "\nThis guide provides an overview of the features, fields, and buttons in the Kemono Downloader.
\nMain Input Area (Top-Left)
\n\n- 🔗 Creator/Post Kemono URL:\n
\n- Enter the full web address of a creator's page (e.g., https://kemono.su/patreon/user/12345) or a specific post (e.g., .../post/98765).
\n- Supports Kemono (kemono.su, kemono.party) and Coomer (coomer.su, coomer.party) URLs.
\n
\n \n- Page Range (Start to End):\n
\n- For creator URLs: Specify a range of pages to grab (e.g., pages 2 to 5). Leave blank for all pages.
\n- Disabled for single post URLs or when Renaming Mode is active.
\n
\n \n- 📁 Download Location:\n
\n- Click 'Browse...' to choose a main folder on your computer where all downloaded files will be saved.
\n- This field is required unless you are using '🔗 Only Links' mode.
\n
\n \n- 🎨 Creator Selection Button (next to URL input):\n
\n- Click the palette icon (🎨) to open the 'Creator Selection' dialog.
\n- This dialog loads creators from your
creators.json file (which must be in the app directory). \n- Inside the dialog:\n
\n- Search bar: Type to filter the creator list by name or service.
\n- Creator list: Displays creators from your
creators.json. Creators you have marked as 'favorites' (in the JSON data) appear at the top. \n- Checkboxes: Select one or more creators by checking the box next to their name.
\n- 'Scope' Button (e.g., 'Scope: Characters'): This button toggles the download organization when initiating downloads from this popup:\n
- Scope: Characters: Downloads will be organized into character-named folders directly in your main 'Download Location'. Art from different creators for the same character will be grouped.
\n- Scope: Creators: Downloads will first create a creator-named folder in your main 'Download Location'. Character-named subfolders will then be created inside each creator's folder.
\n \n- 'Add Selected' Button: Clicking this will take the names of all checked creators and add them to the main '🔗 Creator/Post Kemono URL' input field, separated by commas. The dialog will then close.
\n
\n \n- This feature provides a quick way to populate the URL field for multiple creators without manually typing or pasting each URL.
\n
\n \n
",
"help_guide_step2_title": "② Filtering Downloads",
"help_guide_step2_content": "\nFiltering Downloads (Left Panel)
\n\n- 🎯 Filter by Character(s):\n
\n- Enter names, comma-separated (e.g.,
Tifa, Aerith). \n- Grouped Aliases for Shared Folder (Separate Known.txt entries):
(Vivi, Ulti, Uta).\n- Content matching \"Vivi\", \"Ulti\", OR \"Uta\" will go into a shared folder named \"Vivi Ulti Uta\" (after cleanup).
\n- If these names are new, you will be prompted to add \"Vivi\", \"Ulti\", and \"Uta\" as separate individual entries to
Known.txt. \n
\n \n- Grouped Aliases for Shared Folder (Single Known.txt entry):
(Yuffie, Sonon)~ (note the tilde ~).\n- Content matching \"Yuffie\" OR \"Sonon\" will go into a shared folder named \"Yuffie Sonon\".
\n- If new, \"Yuffie Sonon\" (with aliases Yuffie, Sonon) will be proposed to be added as a single group entry to
Known.txt. \n
\n \n- This filter influences folder naming if 'Separate folders by Known.txt' is enabled.
\n
\n \n- Filter: [Type] Button (Character Filter Scope): Cycles how 'Filter by Character(s)' applies:\n
\nFilter: Files: Checks individual filenames. A post is kept if a file matches; only matching files are downloaded. Folder naming uses the character from the matching filename. \nFilter: Title: Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title. \nFilter: Both: Checks post title first. If it matches, all files are downloaded. If not, it then checks filenames, and only matching files are downloaded. Folder naming prioritizes the title match, then the file match. \nFilter: Comments (Beta): Checks filenames first. If a file matches, all files in the post are downloaded. If no file match, it then checks post comments. If a comment matches, all files are downloaded. (Uses more API requests). Folder naming prioritizes the file match, then the comment match. \n
\n \n- 🗄️ Custom Folder Name (Single Post Only):\n
\n- Visible and usable only when downloading a specific post URL AND 'Separate folders by Known.txt' is enabled.
\n- Allows specifying a custom name for that single post's download folder.
\n
\n \n- 🚫 Skip with words:\n
- Enter words, comma-separated (e.g.,
WIP, sketch, preview) to ignore certain content.
\n \n- Scope: [Type] Button (Skip Words Scope): Cycles how 'Skip with words' applies:\n
\nScope: Files: Skips individual files if their names contain any of these words. \nScope: Posts: Skips entire posts if their titles contain any of these words. \nScope: Both: Applies both (post title first, then individual files). \n
\n \n- ✂️ Remove words from name:\n
- Enter words, comma-separated (e.g.,
patreon, [HD]), to be removed from downloaded filenames (case-insensitive).
\n \n- Filter Files (Radio Buttons): Choose what to download:\n
\nAll: Downloads all file types found. \nImages/GIFs: Only common image formats (JPG, PNG, GIF, WEBP, etc.) and GIFs. \nVideos: Only common video formats (MP4, MKV, WEBM, MOV, etc.). \n📦 Only Archives: Exclusively downloads Archives and .rar files. When this is selected, the 'Skip Archives' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show external links' is also disabled. \n🎧 Only Audio: Downloads only common audio formats (MP3, WAV, FLAC, M4A, OGG, etc.). Other file-specific options behave as in 'Images' or 'Videos' mode. \n🔗 Only Links: Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show external links' are disabled. The main download button becomes '🔗 Extract Links'. \n
\n \n
",
"help_guide_step3_title": "③ Download Options & Settings",
@@ -2916,11 +2916,11 @@ translations ["en"]={
"help_guide_step4_title": "④ Advanced Settings (Part 1)",
"help_guide_step4_content": "⚙️ Advanced Settings (Continued)
\n- Subfolder per post: If 'Separate Folders' is on, this creates an additional subfolder for each individual post inside the main character/title folder.
\n- Use cookie: Check this box to use cookies for requests.\n
\n- Text Field: Enter a cookie string directly (e.g.,
name1=value1; name2=value2). \n- Browse...: Select a
cookies.txt file (Netscape format). The path will appear in the text field. \n- Priority: The text field (if filled) takes priority over a browsed file. If 'Use cookie' is checked but both are empty, it attempts to load
cookies.txt from the app's directory. \n
\n \n- Use multithreading & Threads Input:\n
\n- Enables faster operations. The number in the 'Threads' input means:\n
\n- For Creator Feeds: Number of posts to process simultaneously. Files from each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).
\n- For Single Post URLs: Number of files to download simultaneously from that single post.
\n
\n \n- If unchecked, 1 thread is used. High thread counts (e.g., >40) may show a warning.
\n
\n
",
"help_guide_step5_title": "⑤ Advanced Settings (Part 2) & Actions",
- "help_guide_step5_content": "⚙️ Advanced Settings (Continued)
\n- Show external links in log: If checked, a secondary log panel appears below the main log to display external links found in post descriptions. (Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).
\n- 📖 Manga/Comic Mode (Creator URLs only): Designed for sequential content.\n
\n- Downloads posts from oldest to newest.
\n- The 'Page Range' input is disabled as all posts are fetched.
\n- A filename style toggle button (e.g., 'Name: Post Title') appears at the top-right of the log area when this mode is active for a creator feed. Click it to cycle between naming styles:\n
\nName: Post Title (Default): The first file in a post is named after the cleaned post title (e.g., 'My Chapter 1.jpg'). Subsequent files in the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics. \nName: Original File: All files attempt to keep their original filenames. \nName: Original File: All files attempt to keep their original filenames. When this style is active, an input field for an optional filename prefix (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_OriginalFile.jpg'. \nName: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the cleaned post title as a prefix, followed by a global counter. E.g.: Post 'Chapter 1' (2 files) -> 'Chapter 1 001.jpg', 'Chapter 1 002.png'. Next post 'Chapter 2' (1 file) -> 'Chapter 2 003.jpg'. Multithreading for post processing is automatically disabled for this style. \nName: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on the publish order. When this style is active, an input field for an optional filename prefix (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style. \n
\n \n- For best results with the 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
\n
\n \n
\nMain Actions (Left Panel)
\n\n- ⬇️ Start Download / 🔗 Extract Links: This button's text and function changes based on the 'Filter Files' radio button selection. It starts the main operation.
\n- ⏸️ Pause Download / ▶️ Resume Download: Allows for temporarily halting the current download/extraction process and resuming it later. Some UI settings can be changed while paused.
\n- ❌ Cancel & Reset UI: Stops the current operation and performs a soft reset of the UI. Your URL and download directory inputs are kept, but other settings and logs are cleared.
\n
",
+ "help_guide_step5_content": "⚙️ Advanced Settings (Continued)
\n- Show external links in log: If checked, a secondary log panel appears below the main log to display external links found in post descriptions. (Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).
\n- 📖 Renaming Mode (Creator URLs only): Designed for sequential content.\n
\n- Downloads posts from oldest to newest.
\n- The 'Page Range' input is disabled as all posts are fetched.
\n- A filename style toggle button (e.g., 'Name: Post Title') appears at the top-right of the log area when this mode is active for a creator feed. Click it to cycle between naming styles:\n
\nName: Post Title (Default): The first file in a post is named after the cleaned post title (e.g., 'My Chapter 1.jpg'). Subsequent files in the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics. \nName: Original File: All files attempt to keep their original filenames. \nName: Original File: All files attempt to keep their original filenames. When this style is active, an input field for an optional filename prefix (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_OriginalFile.jpg'. \nName: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the cleaned post title as a prefix, followed by a global counter. E.g.: Post 'Chapter 1' (2 files) -> 'Chapter 1 001.jpg', 'Chapter 1 002.png'. Next post 'Chapter 2' (1 file) -> 'Chapter 2 003.jpg'. Multithreading for post processing is automatically disabled for this style. \nName: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on the publish order. When this style is active, an input field for an optional filename prefix (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style. \n
\n \n- For best results with the 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
\n
\n \n
\nMain Actions (Left Panel)
\n\n- ⬇️ Start Download / 🔗 Extract Links: This button's text and function changes based on the 'Filter Files' radio button selection. It starts the main operation.
\n- ⏸️ Pause Download / ▶️ Resume Download: Allows for temporarily halting the current download/extraction process and resuming it later. Some UI settings can be changed while paused.
\n- ❌ Cancel & Reset UI: Stops the current operation and performs a soft reset of the UI. Your URL and download directory inputs are kept, but other settings and logs are cleared.
\n
",
"help_guide_step6_title": "⑥ Known Series/Characters List",
"help_guide_step6_content": "\nManaging the Known Series/Characters List (Bottom-Left)
\nThis section helps manage the Known.txt file, which is used for smart folder organization when 'Separate folders by Known.txt' is on, especially as a fallback if a post doesn't match your active 'Filter by Character(s)' input.
\n\n- Open Known.txt: Opens the
Known.txt file (located in the app directory) in your default text editor for advanced editing (like creating complex grouped aliases). \n- Search characters...: Filters the list of known names displayed below.
\n- List Widget: Displays the master names from your
Known.txt. Select entries here to delete them. \n- Add new series/character name (Input Field): Enter a name or group to add.\n
\n- Simple Name: e.g.,
My Awesome Series. Adds as a single entry. \n- Group for separate Known.txt entries: e.g.,
(Vivi, Ulti, Uta). Adds \"Vivi\", \"Ulti\", and \"Uta\" as three separate, individual entries to Known.txt. \n- Group for Shared Folder & Single Known.txt Entry (Tilde
~): e.g., (Character A, Char A)~. Adds an entry to Known.txt named \"Character A Char A\". \"Character A\" and \"Char A\" become aliases for this single folder/entry. \n
\n \n- Button ➕ Add: Adds the name/group from the input field above to the list and to
Known.txt. \n- Button ⤵️ Add to Filter:\n
\n- Located next to the '➕ Add' button for the 'Known Series/Characters' list.
\n- Clicking this opens a popup window showing all names from your
Known.txt file, each with a checkbox. \n- The popup includes a search bar to quickly filter the list of names.
\n- You can select one or more names using the checkboxes.
\n- Click 'Add Selected' to insert the chosen names into the main window's 'Filter by Character(s)' input field.
\n- If a selected name in
Known.txt was originally a group (e.g., defined as (Boa, Hancock) in Known.txt), it will be added to the filter field as (Boa, Hancock)~. Simple names are added as-is. \n- 'Select All' and 'Deselect All' buttons are available in the popup for convenience.
\n- Click 'Cancel' to close the popup without any changes.
\n
\n \n- Button 🗑️ Delete Selected: Deletes the selected name(s) from the list and from
Known.txt. \n- Button ❓ (This one!): Displays this comprehensive help guide.
\n
",
"help_guide_step7_title": "⑦ Log Area & Controls",
- "help_guide_step7_content": "\nLog Area & Controls (Right Panel)
\n\n- 📜 Progress Log / Extracted Links Log (Label): Title for the main log area; changes if '🔗 Only Links' mode is active.
\n- Search links... / Button 🔍 (Link Search):\n
- Visible only when '🔗 Only Links' mode is active. Allows for real-time filtering of the extracted links shown in the main log by text, URL, or platform.
\n \n- Name: [Style] Button (Manga Filename Style):\n
- Visible only when Manga/Comic Mode is active for a creator feed and not in 'Links Only' or 'Archives Only' mode.
\n- Cycles through filename styles:
Post Title, Original File, Date Based. (See Manga/Comic Mode section for details). \n- When 'Original File' or 'Date Based' style is active, an input field for an optional filename prefix will appear next to this button.
\n
\n \n- Multi-part: [ON/OFF] Button:\n
- Toggles multi-segment downloads for individual large files.\n
- ON: Can speed up large file downloads (e.g., videos) but may increase UI stutter or log spam with many small files. A warning appears on activation. If a multipart download fails, it retries as a single stream.
\n- OFF (Default): Files are downloaded in a single stream.
\n
\n - Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active.
\n
\n \n- Button 👁️ / 🙈 (Log Display Toggle): Changes the main log view:\n
\n- 👁️ Progress Log (Default): Shows all download activity, errors, and summaries.
\n- 🙈 Missed Character Log: Displays a list of key terms from post titles/content that were skipped due to your 'Filter by Character(s)' settings. Useful for identifying content you might be unintentionally missing.
\n
\n \n- Button 🔄 Reset: Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.
\n- Main Log Output (Text Area): Displays detailed progress messages, errors, and summaries. If '🔗 Only Links' mode is active, this area displays the extracted links.
\n- Missed Character Log Output (Text Area): (Visible via 👁️ / 🙈 toggle) Shows posts/files skipped due to character filters.
\n- External Log Output (Text Area): Appears below the main log if 'Show external links in log' is checked. Displays external links found in post descriptions.
\n- Export Links Button:\n
- Visible and enabled only when '🔗 Only Links' mode is active and links have been extracted.
\n- Allows saving all extracted links to a
.txt file. \n
\n \n- Progress Label: [Status]: Displays the overall progress of the download or link extraction process (e.g., posts processed).
\n- File Progress Label: Displays the progress of individual file downloads, including speed and size, or multipart download status.
\n
",
+ "help_guide_step7_content": "\nLog Area & Controls (Right Panel)
\n\n- 📜 Progress Log / Extracted Links Log (Label): Title for the main log area; changes if '🔗 Only Links' mode is active.
\n- Search links... / Button 🔍 (Link Search):\n
- Visible only when '🔗 Only Links' mode is active. Allows for real-time filtering of the extracted links shown in the main log by text, URL, or platform.
\n \n- Name: [Style] Button (Manga Filename Style):\n
- Visible only when Renaming Mode is active for a creator feed and not in 'Links Only' or 'Archives Only' mode.
\n- Cycles through filename styles:
Post Title, Original File, Date Based. (See Renaming Mode section for details). \n- When 'Original File' or 'Date Based' style is active, an input field for an optional filename prefix will appear next to this button.
\n
\n \n- Multi-part: [ON/OFF] Button:\n
- Toggles multi-segment downloads for individual large files.\n
- ON: Can speed up large file downloads (e.g., videos) but may increase UI stutter or log spam with many small files. A warning appears on activation. If a multipart download fails, it retries as a single stream.
\n- OFF (Default): Files are downloaded in a single stream.
\n
\n - Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active.
\n
\n \n- Button 👁️ / 🙈 (Log Display Toggle): Changes the main log view:\n
\n- 👁️ Progress Log (Default): Shows all download activity, errors, and summaries.
\n- 🙈 Missed Character Log: Displays a list of key terms from post titles/content that were skipped due to your 'Filter by Character(s)' settings. Useful for identifying content you might be unintentionally missing.
\n
\n \n- Button 🔄 Reset: Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.
\n- Main Log Output (Text Area): Displays detailed progress messages, errors, and summaries. If '🔗 Only Links' mode is active, this area displays the extracted links.
\n- Missed Character Log Output (Text Area): (Visible via 👁️ / 🙈 toggle) Shows posts/files skipped due to character filters.
\n- External Log Output (Text Area): Appears below the main log if 'Show external links in log' is checked. Displays external links found in post descriptions.
\n- Export Links Button:\n
- Visible and enabled only when '🔗 Only Links' mode is active and links have been extracted.
\n- Allows saving all extracted links to a
.txt file. \n
\n \n- Progress Label: [Status]: Displays the overall progress of the download or link extraction process (e.g., posts processed).
\n- File Progress Label: Displays the progress of individual file downloads, including speed and size, or multipart download status.
\n
",
"help_guide_step8_title": "⑧ Favorite Mode & Future Features",
"help_guide_step8_content": "\nFavorite Mode (Downloading from your Kemono.su Favorites)
\nThis mode allows you to download content directly from artists you have favorited on Kemono.su.
\n\n- ⭐ How to Activate:\n
\n- Check the '⭐ Favorite Mode' checkbox, located next to the '🔗 Only Links' radio button.
\n
\n \n- UI Changes in Favorite Mode:\n
\n- The '🔗 Creator/Post Kemono URL' input area is replaced with a message indicating Favorite Mode is active.
\n- The standard 'Start Download', 'Pause', 'Cancel' buttons are replaced with:\n
\n- '🖼️ Favorite Artists' button
\n- '📄 Favorite Posts' button
\n
\n \n- The '🍪 Use cookie' option is automatically enabled and locked, as cookies are required to fetch your favorites.
\n
\n \n- Button 🖼️ Favorite Artists:\n
\n- Clicking this opens a dialog that lists all artists you have favorited on Kemono.su.
\n- You can select one or more artists from this list to download their content.
\n
\n \n- Button 📄 Favorite Posts (Future Feature):\n
\n- Downloading specific favorited posts (especially in a sequential, manga-like order if they are part of a series) is a feature currently in development.
\n- The best way to handle favorited posts, particularly for sequential reading like manga, is still being considered.
\n- If you have specific ideas or use-cases for how you'd like to download and organize favorited posts (e.g., \"manga-style\" from favorites), please consider opening an issue or joining the discussion on the project's GitHub page. Your input is valuable!
\n
\n \n- Favorite Download Scope (Button):\n
\n- This button (next to 'Favorite Posts') controls where the selected favorite artists' content is downloaded:\n
\n- Scope: Selected Location: All selected artists are downloaded into the main 'Download Location' you set in the UI. Filters apply globally to all content.
\n- Scope: Artist Folders: For each selected artist, a subfolder (named after the artist) is automatically created inside your main 'Download Location'. That artist's content goes into their specific folder. Filters apply within each artist's dedicated folder.
\n
\n \n
\n \n- Filters in Favorite Mode:\n
\n- The '🎯 Filter by Character(s)', '🚫 Skip with words', and 'Filter Files' options you have set in the UI will still apply to the content downloaded from your selected favorite artists.
\n
\n \n
",
"help_guide_step9_title": "⑨ Key Files & Tour",
diff --git a/src/ui/dialogs/ErrorFilesDialog.py b/src/ui/dialogs/ErrorFilesDialog.py
index 899412c..a8f1fae 100644
--- a/src/ui/dialogs/ErrorFilesDialog.py
+++ b/src/ui/dialogs/ErrorFilesDialog.py
@@ -106,7 +106,17 @@ class ErrorFilesDialog(QDialog):
post_title = error_info.get('post_title', 'Unknown Post')
post_id = error_info.get('original_post_id_for_log', 'N/A')
- item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})"
+ creator_name = "Unknown Creator"
+ service = error_info.get('service')
+ user_id = error_info.get('user_id')
+
+ # Check if we have the necessary info and access to the cache
+ if service and user_id and hasattr(self.parent_app, 'creator_name_cache'):
+ creator_key = (service.lower(), str(user_id))
+ # Look up the name, fall back to the user_id if not found
+ creator_name = self.parent_app.creator_name_cache.get(creator_key, user_id)
+
+ item_text = f"File: {filename}\nCreator: {creator_name} - Post: '{post_title}' (ID: {post_id})"
list_item = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, error_info)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
diff --git a/src/ui/dialogs/FutureSettingsDialog.py b/src/ui/dialogs/FutureSettingsDialog.py
index fde761c..d8534e6 100644
--- a/src/ui/dialogs/FutureSettingsDialog.py
+++ b/src/ui/dialogs/FutureSettingsDialog.py
@@ -4,24 +4,109 @@ import json
import sys
# --- PyQt5 Imports ---
-from PyQt5.QtCore import Qt, QStandardPaths
+from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
)
-
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ...utils.resolution import get_dark_theme
+from ..assets import get_app_icon_object
+
from ..main_window import get_app_icon_object
from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_KEY,
- FETCH_FIRST_KEY
+ FETCH_FIRST_KEY, DISCORD_TOKEN_KEY, POST_DOWNLOAD_ACTION_KEY
)
from ...services.updater import UpdateChecker, UpdateDownloader
+class CountdownMessageBox(QDialog):
+ """
+ A custom message box that includes a countdown timer for the 'Yes' button,
+ which automatically accepts the dialog when the timer reaches zero.
+ """
+ def __init__(self, title, text, countdown_seconds=10, parent_app=None, parent=None):
+ super().__init__(parent)
+ self.parent_app = parent_app
+ self.countdown = countdown_seconds
+
+ # --- Basic Window Setup ---
+ self.setWindowTitle(title)
+ self.setModal(True)
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ self._init_ui(text)
+ self._apply_theme()
+
+ # --- Timer Setup ---
+ self.timer = QTimer(self)
+ self.timer.setInterval(1000) # Tick every second
+ self.timer.timeout.connect(self._update_countdown)
+ self.timer.start()
+
+ def _init_ui(self, text):
+ """Initializes the UI components of the dialog."""
+ main_layout = QVBoxLayout(self)
+
+ self.message_label = QLabel(text)
+ self.message_label.setWordWrap(True)
+ self.message_label.setAlignment(Qt.AlignCenter)
+ main_layout.addWidget(self.message_label)
+
+ buttons_layout = QHBoxLayout()
+ buttons_layout.addStretch(1)
+
+ self.yes_button = QPushButton()
+ self.yes_button.clicked.connect(self.accept)
+ self.yes_button.setDefault(True)
+
+ self.no_button = QPushButton()
+ self.no_button.clicked.connect(self.reject)
+
+ buttons_layout.addWidget(self.yes_button)
+ buttons_layout.addWidget(self.no_button)
+ buttons_layout.addStretch(1)
+
+ main_layout.addLayout(buttons_layout)
+
+ self._retranslate_ui()
+ self._update_countdown() # Initial text setup
+
+ def _tr(self, key, default_text=""):
+ """Helper for translations."""
+ if self.parent_app and hasattr(self.parent_app, 'current_selected_language'):
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets translated text for UI elements."""
+ self.no_button.setText(self._tr("no_button_text", "No"))
+ # The 'yes' button text is handled by the countdown
+
+ def _update_countdown(self):
+ """Updates the countdown and button text each second."""
+ if self.countdown <= 0:
+ self.timer.stop()
+ self.accept() # Automatically accept when countdown finishes
+ return
+
+ yes_text = self._tr("yes_button_text", "Yes")
+ self.yes_button.setText(f"{yes_text} ({self.countdown})")
+ self.countdown -= 1
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
+ scale = getattr(self.parent_app, 'scale_factor', 1)
+ self.setStyleSheet(get_dark_theme(scale))
+ else:
+ self.setStyleSheet("")
+
class FutureSettingsDialog(QDialog):
"""
A dialog for managing application-wide settings like theme, language,
@@ -39,7 +124,7 @@ class FutureSettingsDialog(QDialog):
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0
- base_min_w, base_min_h = 420, 480 # Increased height for update section
+ base_min_w, base_min_h = 420, 520 # Increased height for new options
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
@@ -55,7 +140,6 @@ class FutureSettingsDialog(QDialog):
self.interface_group_box = QGroupBox()
interface_layout = QGridLayout(self.interface_group_box)
- # Theme, UI Scale, Language (unchanged)...
self.theme_label = QLabel()
self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme)
@@ -87,21 +171,26 @@ class FutureSettingsDialog(QDialog):
self.default_path_label = QLabel()
self.save_path_button = QPushButton()
- self.save_path_button.clicked.connect(self._save_cookie_and_path)
+ self.save_path_button.clicked.connect(self._save_settings)
download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1)
+ self.post_download_action_label = QLabel()
+ self.post_download_action_combo = QComboBox()
+ self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
+ download_window_layout.addWidget(self.post_download_action_label, 2, 0)
+ download_window_layout.addWidget(self.post_download_action_combo, 2, 1)
+
self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
- download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
+ download_window_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2)
self.fetch_first_checkbox = QCheckBox()
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
- download_window_layout.addWidget(self.fetch_first_checkbox, 3, 0, 1, 2)
+ download_window_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box)
- # --- NEW: Update Section ---
self.update_group_box = QGroupBox()
update_layout = QGridLayout(self.update_group_box)
self.version_label = QLabel()
@@ -112,7 +201,6 @@ class FutureSettingsDialog(QDialog):
update_layout.addWidget(self.update_status_label, 0, 1)
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
main_layout.addWidget(self.update_group_box)
- # --- END: New Section ---
main_layout.addStretch(1)
@@ -129,28 +217,27 @@ class FutureSettingsDialog(QDialog):
self.language_label.setText(self._tr("language_label", "Language:"))
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
+ self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
self._update_theme_toggle_button_text()
- self.save_path_button.setText(self._tr("settings_save_cookie_path_button", "Save Cookie + Download Path"))
- self.save_path_button.setToolTip(self._tr("settings_save_cookie_path_tooltip", "Save the current 'Download Location' and Cookie settings for future sessions."))
+ self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
+ self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK"))
- # --- NEW: Translations for Update Section ---
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
current_version = self.parent_app.windowTitle().split(' v')[-1]
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
- # --- END: New Translations ---
-
+
self._populate_display_combo_boxes()
self._populate_language_combo_box()
+ self._populate_post_download_action_combo()
self._load_checkbox_states()
def _check_for_updates(self):
- """Starts the update check thread."""
self.check_update_button.setEnabled(False)
self.update_status_label.setText(self._tr("update_status_checking", "Checking..."))
current_version = self.parent_app.windowTitle().split(' v')[-1]
@@ -189,7 +276,6 @@ class FutureSettingsDialog(QDialog):
self.check_update_button.setEnabled(True)
self.ok_button.setEnabled(True)
- # --- (The rest of the file remains unchanged from your provided code) ---
def _load_checkbox_states(self):
self.save_creator_json_checkbox.blockSignals(True)
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
@@ -252,15 +338,9 @@ class FutureSettingsDialog(QDialog):
self.ui_scale_combo_box.blockSignals(True)
self.ui_scale_combo_box.clear()
scales = [
- (0.5, "50%"),
- (0.7, "70%"),
- (0.9, "90%"),
- (1.0, "100% (Default)"),
- (1.25, "125%"),
- (1.50, "150%"),
- (1.75, "175%"),
- (2.0, "200%")
- ]
+ (0.5, "50%"), (0.7, "70%"), (0.9, "90%"), (1.0, "100% (Default)"),
+ (1.25, "125%"), (1.50, "150%"), (1.75, "175%"), (2.0, "200%")
+ ]
current_scale = self.parent_app.settings.value(UI_SCALE_KEY, 1.0)
for scale_val, scale_name in scales:
self.ui_scale_combo_box.addItem(scale_name, scale_val)
@@ -285,7 +365,7 @@ class FutureSettingsDialog(QDialog):
("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
- ]
+ ]
current_lang = self.parent_app.current_selected_language
for lang_code, lang_name in languages:
self.language_combo_box.addItem(lang_name, lang_code)
@@ -305,14 +385,44 @@ class FutureSettingsDialog(QDialog):
QMessageBox.information(self, self._tr("language_change_title", "Language Changed"),
self._tr("language_change_message", "A restart is required..."))
- def _save_cookie_and_path(self):
+ def _populate_post_download_action_combo(self):
+ """Populates the action dropdown and sets the current selection from settings."""
+ self.post_download_action_combo.blockSignals(True)
+ self.post_download_action_combo.clear()
+
+ actions = [
+ (self._tr("action_off", "Off"), "off"),
+ (self._tr("action_notify", "Notify with Sound"), "notify"),
+ (self._tr("action_sleep", "Sleep"), "sleep"),
+ (self._tr("action_shutdown", "Shutdown"), "shutdown")
+ ]
+
+ current_action = self.parent_app.settings.value(POST_DOWNLOAD_ACTION_KEY, "off")
+
+ for text, key in actions:
+ self.post_download_action_combo.addItem(text, key)
+ if current_action == key:
+ self.post_download_action_combo.setCurrentIndex(self.post_download_action_combo.count() - 1)
+
+ self.post_download_action_combo.blockSignals(False)
+
+ def _post_download_action_changed(self):
+ """Saves the selected post-download action to settings."""
+ selected_action = self.post_download_action_combo.currentData()
+ self.parent_app.settings.setValue(POST_DOWNLOAD_ACTION_KEY, selected_action)
+ self.parent_app.settings.sync()
+
+ def _save_settings(self):
path_saved = False
cookie_saved = False
+ token_saved = False
+
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip()
if current_path and os.path.isdir(current_path):
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
path_saved = True
+
if hasattr(self.parent_app, 'use_cookie_checkbox'):
use_cookie = self.parent_app.use_cookie_checkbox.isChecked()
cookie_content = self.parent_app.cookie_text_input.text().strip()
@@ -323,8 +433,20 @@ class FutureSettingsDialog(QDialog):
else:
self.parent_app.settings.setValue(USE_COOKIE_KEY, False)
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "")
+
+ if (hasattr(self.parent_app, 'remove_from_filename_input') and
+ hasattr(self.parent_app, 'remove_from_filename_label_widget')):
+
+ label_text = self.parent_app.remove_from_filename_label_widget.text()
+ if "Token" in label_text:
+ discord_token = self.parent_app.remove_from_filename_input.text().strip()
+ if discord_token:
+ self.parent_app.settings.setValue(DISCORD_TOKEN_KEY, discord_token)
+ token_saved = True
+
self.parent_app.settings.sync()
- if path_saved or cookie_saved:
- QMessageBox.information(self, "Settings Saved", "Settings have been saved.")
+
+ if path_saved or cookie_saved or token_saved:
+ QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
else:
- QMessageBox.warning(self, "Nothing to Save", "No valid settings to save.")
\ No newline at end of file
+ QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
diff --git a/src/ui/dialogs/discord_pdf_generator.py b/src/ui/dialogs/discord_pdf_generator.py
index ea2f100..49ab403 100644
--- a/src/ui/dialogs/discord_pdf_generator.py
+++ b/src/ui/dialogs/discord_pdf_generator.py
@@ -1,6 +1,7 @@
import os
import re
import datetime
+import time
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
@@ -29,7 +30,7 @@ except ImportError:
FPDF = None
PDF = None
-def create_pdf_from_discord_messages(messages_data, server_name, channel_name, output_filename, font_path, logger=print):
+def create_pdf_from_discord_messages(messages_data, server_name, channel_name, output_filename, font_path, logger=print, cancellation_event=None, pause_event=None):
"""
Creates a single PDF from a list of Discord message objects, formatted as a chat log.
UPDATED to include clickable links for attachments and embeds.
@@ -42,8 +43,20 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
logger(" No messages were found or fetched to create a PDF.")
return False
+ # --- FIX: This helper function now correctly accepts and checks the event objects ---
+ def check_events(c_event, p_event):
+ """Helper to safely check for pause and cancel events."""
+ if c_event and hasattr(c_event, 'is_cancelled') and c_event.is_cancelled:
+ return True # Stop
+ if p_event and hasattr(p_event, 'is_paused'):
+ while p_event.is_paused:
+ time.sleep(0.5)
+ if c_event and hasattr(c_event, 'is_cancelled') and c_event.is_cancelled:
+ return True
+ return False
+
logger(" Sorting messages by date (oldest first)...")
- messages_data.sort(key=lambda m: m.get('published', ''))
+ messages_data.sort(key=lambda m: m.get('published', m.get('timestamp', '')))
pdf = PDF(server_name, channel_name)
default_font_family = 'DejaVu'
@@ -78,14 +91,19 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
logger(f" Starting PDF creation with {len(messages_data)} messages...")
for i, message in enumerate(messages_data):
+ # --- FIX: Pass the event objects to the helper function ---
+ if i % 50 == 0:
+ if check_events(cancellation_event, pause_event):
+ logger(" PDF generation cancelled by user.")
+ return False
+
author = message.get('author', {}).get('global_name') or message.get('author', {}).get('username', 'Unknown User')
- timestamp_str = message.get('published', '')
+ timestamp_str = message.get('published', message.get('timestamp', ''))
content = message.get('content', '')
attachments = message.get('attachments', [])
embeds = message.get('embeds', [])
try:
- # Handle timezone information correctly
if timestamp_str.endswith('Z'):
timestamp_str = timestamp_str[:-1] + '+00:00'
dt_obj = datetime.datetime.fromisoformat(timestamp_str)
@@ -93,14 +111,12 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
except (ValueError, TypeError):
formatted_timestamp = timestamp_str
- # Draw a separator line
if i > 0:
pdf.ln(2)
- pdf.set_draw_color(200, 200, 200) # Light grey line
+ pdf.set_draw_color(200, 200, 200)
pdf.cell(0, 0, '', border='T')
pdf.ln(2)
- # Message Header
pdf.set_font(default_font_family, 'B', 11)
pdf.write(5, f"{author} ")
pdf.set_font(default_font_family, '', 9)
@@ -109,33 +125,31 @@ def create_pdf_from_discord_messages(messages_data, server_name, channel_name, o
pdf.set_text_color(0, 0, 0)
pdf.ln(6)
- # Message Content
if content:
pdf.set_font(default_font_family, '', 10)
pdf.multi_cell(w=0, h=5, text=content)
- # --- START: MODIFIED ATTACHMENT AND EMBED LOGIC ---
if attachments or embeds:
pdf.ln(1)
pdf.set_font(default_font_family, '', 9)
- pdf.set_text_color(22, 119, 219) # A nice blue for links
+ pdf.set_text_color(22, 119, 219)
for att in attachments:
- file_name = att.get('name', 'untitled')
- file_path = att.get('path', '')
- # Construct the full, clickable URL for the attachment
- full_url = f"https://kemono.cr/data{file_path}"
+ file_name = att.get('filename', 'untitled')
+ full_url = att.get('url', '#')
pdf.write(5, text=f"[Attachment: {file_name}]", link=full_url)
- pdf.ln() # New line after each attachment
+ pdf.ln()
for embed in embeds:
embed_url = embed.get('url', 'no url')
- # The embed URL is already a full URL
pdf.write(5, text=f"[Embed: {embed_url}]", link=embed_url)
- pdf.ln() # New line after each embed
+ pdf.ln()
- pdf.set_text_color(0, 0, 0) # Reset color to black
- # --- END: MODIFIED ATTACHMENT AND EMBED LOGIC ---
+ pdf.set_text_color(0, 0, 0)
+
+ if check_events(cancellation_event, pause_event):
+ logger(" PDF generation cancelled by user before final save.")
+ return False
try:
pdf.output(output_filename)
diff --git a/src/ui/main_window.py b/src/ui/main_window.py
index 211bc41..a9eaab1 100644
--- a/src/ui/main_window.py
+++ b/src/ui/main_window.py
@@ -2,6 +2,7 @@ import sys
import os
import time
import queue
+import random
import traceback
import html
import http
@@ -41,6 +42,7 @@ from ..core.nhentai_client import fetch_nhentai_gallery
from ..core.bunkr_client import fetch_bunkr_data
from ..core.saint2_client import fetch_saint2_data
from ..core.erome_client import fetch_erome_data
+from ..core.Hentai2read_client import fetch_hentai2read_data
from .assets import get_app_icon_object
from ..config.constants import *
from ..utils.file_utils import KNOWN_NAMES, clean_folder_name
@@ -53,7 +55,7 @@ from .dialogs.CookieHelpDialog import CookieHelpDialog
from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog
from .dialogs.KnownNamesFilterDialog import KnownNamesFilterDialog
from .dialogs.HelpGuideDialog import HelpGuideDialog
-from .dialogs.FutureSettingsDialog import FutureSettingsDialog
+from .dialogs.FutureSettingsDialog import FutureSettingsDialog, CountdownMessageBox
from .dialogs.ErrorFilesDialog import ErrorFilesDialog
from .dialogs.DownloadHistoryDialog import DownloadHistoryDialog
from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog
@@ -67,6 +69,10 @@ from .dialogs.SupportDialog import SupportDialog
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
from .dialogs.MultipartScopeDialog import MultipartScopeDialog
+_ff_ver = (datetime.date.today().toordinal() - 735506) // 28
+USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; "
+ f"rv:{_ff_ver}.0) Gecko/20100101 Firefox/{_ff_ver}.0")
+
class DynamicFilterHolder:
"""A thread-safe class to hold and update character filters during a download."""
def __init__(self, initial_filters=None):
@@ -286,7 +292,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
- self.setWindowTitle("Kemono Downloader v7.0.0")
+ self.setWindowTitle("Kemono Downloader v7.1.0")
setup_ui(self)
self._connect_signals()
if hasattr(self, 'character_input'):
@@ -305,6 +311,127 @@ class DownloaderApp (QWidget ):
self._check_for_interrupted_session()
self._cleanup_after_update()
+ def _run_discord_file_download_thread(self, session, server_id, channel_id, token, output_dir, message_limit=None):
+ """
+ Runs in a background thread to fetch and download all files from a Discord channel.
+ """
+ def queue_logger(message):
+ self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)})
+
+ def queue_progress_label_update(message):
+ self.worker_to_gui_queue.put({'type': 'set_progress_label', 'payload': (message,)})
+
+ def check_events():
+ if self.cancellation_event.is_set():
+ return True # Stop
+ while self.pause_event.is_set():
+ time.sleep(0.5) # Wait while paused
+ if self.cancellation_event.is_set():
+ return True # Allow cancelling while paused
+ return False # Continue
+
+ download_count = 0
+ skip_count = 0
+
+ try:
+ queue_logger("=" * 40)
+ queue_logger(f"🚀 Starting Discord download for channel: {channel_id}")
+ queue_progress_label_update("Fetching messages...")
+
+ def fetch_discord_api(endpoint):
+ headers = {
+ 'Authorization': token,
+ 'User-Agent': USERAGENT_FIREFOX,
+ 'Accept': '*/*',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ }
+ try:
+ response = session.get(f"https://discord.com/api/v10{endpoint}", headers=headers)
+ response.raise_for_status()
+ return response.json()
+ except Exception:
+ return None
+
+ last_message_id = None
+ all_messages = []
+
+ while True:
+ if check_events(): break
+
+ url_endpoint = f"/channels/{channel_id}/messages?limit=100"
+ if last_message_id:
+ url_endpoint += f"&before={last_message_id}"
+
+ message_batch = fetch_discord_api(url_endpoint)
+ if not message_batch:
+ break
+
+ all_messages.extend(message_batch)
+
+ if message_limit and len(all_messages) >= message_limit:
+ queue_logger(f" Reached message limit of {message_limit}. Halting fetch.")
+ all_messages = all_messages[:message_limit]
+ break
+
+ last_message_id = message_batch[-1]['id']
+ queue_progress_label_update(f"Fetched {len(all_messages)} messages...")
+ time.sleep(1)
+
+ if self.cancellation_event.is_set():
+ self.finished_signal.emit(0, 0, True, [])
+ return
+
+ queue_progress_label_update(f"Collected {len(all_messages)} messages. Starting downloads...")
+ total_attachments = sum(len(m.get('attachments', [])) for m in all_messages)
+
+ for message in reversed(all_messages):
+ if check_events(): break
+ for attachment in message.get('attachments', []):
+ if check_events(): break
+
+ file_url = attachment['url']
+ original_filename = attachment['filename']
+ filepath = os.path.join(output_dir, original_filename)
+ filename_to_use = original_filename
+
+ counter = 1
+ base_name, extension = os.path.splitext(original_filename)
+ while os.path.exists(filepath):
+ filename_to_use = f"{base_name} ({counter}){extension}"
+ filepath = os.path.join(output_dir, filename_to_use)
+ counter += 1
+
+ if filename_to_use != original_filename:
+ queue_logger(f" -> Duplicate name '{original_filename}'. Saving as '{filename_to_use}'.")
+
+ try:
+ queue_logger(f" Downloading ({download_count+1}/{total_attachments}): '{filename_to_use}'...")
+ # --- FIX: Stream the download in chunks for responsive controls ---
+ response = requests.get(file_url, stream=True, timeout=60)
+ response.raise_for_status()
+
+ download_cancelled = False
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if check_events():
+ download_cancelled = True
+ break
+ f.write(chunk)
+
+ if download_cancelled:
+ queue_logger(f" Download cancelled for '{filename_to_use}'. Deleting partial file.")
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ continue # Move to the next attachment
+
+ download_count += 1
+ except Exception as e:
+ queue_logger(f" ❌ Failed to download '{filename_to_use}': {e}")
+ skip_count += 1
+
+ finally:
+ self.finished_signal.emit(download_count, skip_count, self.cancellation_event.is_set(), [])
+
def _cleanup_after_update(self):
"""Deletes the old executable after a successful update."""
try:
@@ -805,7 +932,7 @@ class DownloaderApp (QWidget ):
if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setText (self ._tr ("use_cookie_checkbox_label","Use Cookie"))
if hasattr (self ,'use_multithreading_checkbox'):self .update_multithreading_label (self .thread_count_input .text ()if hasattr (self ,'thread_count_input')else "1")
if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setText (self ._tr ("show_external_links_checkbox_label","Show External Links in Log"))
- if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Manga/Comic Mode"))
+ if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Renaming Mode"))
if hasattr (self ,'thread_count_label'):self .thread_count_label .setText (self ._tr ("threads_label","Threads:"))
if hasattr (self ,'character_input'):
@@ -1202,64 +1329,83 @@ class DownloaderApp (QWidget ):
)
pdf_thread.start()
- def _run_discord_pdf_creation_thread(self, api_url, server_id, channel_id, output_filepath):
+ def _run_discord_pdf_creation_thread(self, session, api_url, server_id, channel_id, output_filepath, message_limit=None):
def queue_logger(message):
self.worker_to_gui_queue.put({'type': 'progress', 'payload': (message,)})
def queue_progress_label_update(message):
self.worker_to_gui_queue.put({'type': 'set_progress_label', 'payload': (message,)})
+ token = self.remove_from_filename_input.text().strip()
+ headers = {
+ 'Authorization': token,
+ 'User-Agent': USERAGENT_FIREFOX,
+ }
+
self.set_ui_enabled(False)
queue_logger("=" * 40)
queue_logger(f"🚀 Starting Discord PDF export for: {api_url}")
queue_progress_label_update("Fetching messages...")
all_messages = []
- cookies = prepare_cookies_for_request(
- self.use_cookie_checkbox.isChecked(), self.cookie_text_input.text(),
- self.selected_cookie_filepath, self.app_base_dir, queue_logger # Use safe logger
- )
-
channels_to_process = []
server_name_for_pdf = server_id
if channel_id:
channels_to_process.append({'id': channel_id, 'name': channel_id})
else:
- channels = fetch_server_channels(server_id, queue_logger, cookies) # Use safe logger
- if channels:
- channels_to_process = channels
- # In a real scenario, you'd get the server name from an API. We'll use the ID.
- server_name_for_pdf = server_id
- else:
- queue_logger(f"❌ Could not find any channels for server {server_id}.")
- self.worker_to_gui_queue.put({'type': 'set_ui_enabled', 'payload': (True,)})
- return
+ # This logic can be expanded later to fetch all channels in a server if needed
+ pass
- # Fetch messages for all required channels
for i, channel in enumerate(channels_to_process):
queue_progress_label_update(f"Fetching from channel {i+1}/{len(channels_to_process)}: #{channel.get('name', '')}")
- message_generator = fetch_channel_messages(channel['id'], queue_logger, self.cancellation_event, self.pause_event, cookies) # Use safe logger
- for message_batch in message_generator:
+ last_message_id = None
+ while not self.cancellation_event.is_set():
+ url_endpoint = f"/channels/{channel['id']}/messages?limit=100"
+ if last_message_id:
+ url_endpoint += f"&before={last_message_id}"
+
+ try:
+ resp = session.get(f"https://discord.com/api/v10{url_endpoint}", headers=headers)
+ resp.raise_for_status()
+ message_batch = resp.json()
+ except Exception:
+ message_batch = []
+
+ if not message_batch:
+ break
+
all_messages.extend(message_batch)
+
+ if message_limit and len(all_messages) >= message_limit:
+ queue_logger(f" Reached message limit of {message_limit}. Halting fetch.")
+ all_messages = all_messages[:message_limit]
+ break
+
+ last_message_id = message_batch[-1]['id']
+ queue_progress_label_update(f"Fetched {len(all_messages)} messages...")
+ time.sleep(1)
+
+ if message_limit and len(all_messages) >= message_limit:
+ break
queue_progress_label_update(f"Collected {len(all_messages)} total messages. Generating PDF...")
- # Determine font path
+ all_messages.reverse()
+
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS
else:
base_path = self.app_base_dir
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
- # Generate the PDF
success = create_pdf_from_discord_messages(
all_messages,
server_name_for_pdf,
channels_to_process[0].get('name', channel_id) if len(channels_to_process) == 1 else "All Channels",
output_filepath,
font_path,
- logger=queue_logger # Use safe logger
+ logger=queue_logger
)
if success:
@@ -1267,9 +1413,7 @@ class DownloaderApp (QWidget ):
else:
queue_progress_label_update(f"❌ PDF export failed. Check log for details.")
- queue_logger("=" * 40)
- # Safely re-enable the UI from the main thread via the queue
- self.worker_to_gui_queue.put({'type': 'set_ui_enabled', 'payload': (True,)})
+ self.finished_signal.emit(0, len(all_messages), self.cancellation_event.is_set(), [])
def save_known_names(self):
"""
@@ -3149,7 +3293,8 @@ class DownloaderApp (QWidget ):
self.use_cookie_checkbox, self.keep_duplicates_checkbox, self.date_prefix_checkbox,
self.manga_rename_toggle_button, self.manga_date_prefix_input,
self.multipart_toggle_button, self.custom_folder_input, self.custom_folder_label,
- self.discord_scope_toggle_button, self.save_discord_as_pdf_btn
+ self.discord_scope_toggle_button
+ # --- FIX: REMOVED self.save_discord_as_pdf_btn from this list ---
]
enable_state = not is_specialized
@@ -3189,21 +3334,42 @@ class DownloaderApp (QWidget ):
url_text = self.link_input.text().strip()
service, _, _ = extract_post_info(url_text)
-
- # Handle specialized downloaders (Bunkr, nhentai)
+
+ # --- FIX: Use two separate flags for better control ---
+ # This is true for BOTH kemono.cr/discord and discord.com
+ is_any_discord_url = (service == 'discord')
+ # This is ONLY true for official discord.com
+ is_official_discord_url = 'discord.com' in url_text and is_any_discord_url
+
+ if is_official_discord_url:
+ # Show the token input only for the official site
+ self.remove_from_filename_label_widget.setText("🔑 Discord Token:")
+ self.remove_from_filename_input.setPlaceholderText("Enter your Discord Authorization Token here")
+ self.remove_from_filename_input.setEchoMode(QLineEdit.Password)
+ saved_token = self.settings.value(DISCORD_TOKEN_KEY, "")
+ if saved_token:
+ self.remove_from_filename_input.setText(saved_token)
+ else:
+ # Revert to the standard input for Kemono, Coomer, etc.
+ self.remove_from_filename_label_widget.setText(self._tr("remove_words_from_name_label", "✂️ Remove Words from name:"))
+ self.remove_from_filename_input.setPlaceholderText(self._tr("remove_from_filename_input_placeholder_text", "e.g., patreon, HD"))
+ self.remove_from_filename_input.setEchoMode(QLineEdit.Normal)
+
+ # Handle other specialized downloaders (Bunkr, nhentai, etc.)
is_saint2 = 'saint2.su' in url_text or 'saint2.pk' in url_text
is_erome = 'erome.com' in url_text
- is_specialized = service in ['bunkr', 'nhentai'] or is_saint2 or is_erome
+ is_specialized = service in ['bunkr', 'nhentai', 'hentai2read'] or is_saint2 or is_erome
self._set_ui_for_specialized_downloader(is_specialized)
- # Handle Discord UI
- is_discord = (service == 'discord')
- self.discord_scope_toggle_button.setVisible(is_discord)
- self.save_discord_as_pdf_btn.setVisible(is_discord)
+ # --- FIX: Show the Scope button for ANY Discord URL (Kemono or official) ---
+ self.discord_scope_toggle_button.setVisible(is_any_discord_url)
+ if hasattr(self, 'discord_message_limit_input'):
+ # Only show the message limit for the official site, as it's an API feature
+ self.discord_message_limit_input.setVisible(is_official_discord_url)
- if is_discord:
+ if is_any_discord_url:
self._update_discord_scope_button_text()
- elif not is_specialized: # Don't change button text for specialized downloaders
+ elif not is_specialized:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
def _update_discord_scope_button_text(self):
@@ -3475,6 +3641,72 @@ class DownloaderApp (QWidget ):
service, id1, id2 = extract_post_info(api_url)
+ if 'discord.com' in api_url and service == 'discord':
+ server_id, channel_id = id1, id2
+ token = self.remove_from_filename_input.text().strip()
+ output_dir = self.dir_input.text().strip()
+
+ if not token or not output_dir:
+ QMessageBox.critical(self, "Input Error", "A Discord Token and Download Location are required.")
+ return False
+
+ limit_text = self.discord_message_limit_input.text().strip()
+ message_limit = int(limit_text) if limit_text else None
+ if message_limit:
+ self.log_signal.emit(f"ℹ️ Applying message limit: will fetch up to {message_limit} latest messages.")
+
+ mode = 'pdf' if self.discord_download_scope == 'messages' else 'files'
+
+ # 1. Create the thread object
+ self.download_thread = DiscordDownloadThread(
+ mode=mode, session=requests.Session(), token=token, output_dir=output_dir,
+ server_id=server_id, channel_id=channel_id, url=api_url, limit=message_limit, parent=self
+ )
+
+ # 2. Connect its signals to the main window's functions
+ self.download_thread.progress_signal.connect(self.handle_main_log)
+ self.download_thread.progress_label_signal.connect(self.progress_label.setText)
+ self.download_thread.finished_signal.connect(self.download_finished)
+
+ # --- FIX: Start the thread BEFORE updating the UI ---
+ # 3. Start the download process in the background
+ self.download_thread.start()
+
+ # 4. NOW, update the UI. The app knows a download is active.
+ self.set_ui_enabled(False)
+ self._update_button_states_and_connections()
+
+ return True
+
+ if service == 'hentai2read':
+ self.log_signal.emit("=" * 40)
+ self.log_signal.emit(f"🚀 Detected Hentai2Read gallery: {id1}")
+
+ if not effective_output_dir_for_run or not os.path.isdir(effective_output_dir_for_run):
+ QMessageBox.critical(self, "Input Error", "A valid Download Location is required.")
+ return False
+
+ self.set_ui_enabled(False)
+ self.download_thread = Hentai2readDownloadThread(
+ base_url="https://hentai2read.com",
+ manga_slug=id1,
+ chapter_num=id2,
+ output_dir=effective_output_dir_for_run,
+ pause_event=self.pause_event,
+ parent=self
+ )
+
+ self.download_thread.progress_signal.connect(self.handle_main_log)
+ self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
+ self.download_thread.overall_progress_signal.connect(self.update_progress_display)
+ self.download_thread.finished_signal.connect(
+ lambda dl, skip, cancelled: self.download_finished(dl, skip, cancelled, [])
+ )
+ self.download_thread.start()
+ self._update_button_states_and_connections()
+ return True
+
+
if service == 'nhentai':
gallery_id = id1
self.log_signal.emit("=" * 40)
@@ -3874,11 +4106,11 @@ class DownloaderApp (QWidget ):
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Manga Mode & Page Range Warning")
msg_box.setText(
- "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n"
- "Manga Mode processes posts from oldest to newest across all available pages by default.\n"
- "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n"
- "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n"
- "Do you want to proceed with this page range in Manga Mode?"
+ "You have enabled Renaming Mode with a sequential naming style (Date Based or Title + G.Num) and also specified a Page Range.\n\n"
+ "These modes rely on processing all posts from the beginning to create a correct sequence. "
+ "Using a page range may result in an incomplete or incorrectly ordered download.\n\n"
+ "It is recommended to use these styles without a page range.\n\n"
+ "Do you want to proceed anyway?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
@@ -4345,7 +4577,8 @@ class DownloaderApp (QWidget ):
self.discord_scope_toggle_button.setVisible(is_discord)
if hasattr(self, 'save_discord_as_pdf_btn'):
self.save_discord_as_pdf_btn.setVisible(is_discord)
-
+ if hasattr(self, 'discord_message_limit_input'):
+ self.discord_message_limit_input.setVisible(is_discord)
if is_discord:
self._update_discord_scope_button_text()
else:
@@ -4909,16 +5142,29 @@ class DownloaderApp (QWidget ):
self .update_ui_for_subfolders (subfolders_currently_on )
self ._handle_favorite_mode_toggle (is_fav_mode_active )
- def _handle_pause_resume_action (self ):
- if self ._is_download_active ():
- self .is_paused =not self .is_paused
- if self .is_paused :
- if self .pause_event :self .pause_event .set ()
- self .log_signal .emit ("ℹ️ Download paused by user. Some settings can now be changed for subsequent operations.")
- else :
- if self .pause_event :self .pause_event .clear ()
- self .log_signal .emit ("ℹ️ Download resumed by user.")
- self .set_ui_enabled (False )
+ def _handle_pause_resume_action(self):
+ # --- FIX: Simplified and corrected the pause/resume logic ---
+ if not self._is_download_active():
+ return
+
+ # Toggle the main app's pause state tracker
+ self.is_paused = not self.is_paused
+
+ # Call the correct method on the thread based on the new state
+ if isinstance(self.download_thread, DiscordDownloadThread):
+ if self.is_paused:
+ self.download_thread.pause()
+ else:
+ self.download_thread.resume()
+ else:
+ # Fallback for older download types
+ if self.is_paused:
+ self.pause_event.set()
+ else:
+ self.pause_event.clear()
+
+ # This call correctly updates the button's text to "Pause" or "Resume"
+ self.set_ui_enabled(False)
def _perform_soft_ui_reset (self ,preserve_url =None ,preserve_dir =None ):
"""Resets UI elements and some state to app defaults, then applies preserved inputs."""
@@ -5016,16 +5262,12 @@ class DownloaderApp (QWidget ):
self ._filter_links_log ()
def cancel_download_button_action(self):
- """
- Signals all active download processes to cancel but DOES NOT reset the UI.
- The UI reset is now handled by the 'download_finished' method.
- """
- if self.cancellation_event.is_set():
- self.log_signal.emit("ℹ️ Cancellation is already in progress.")
- return
-
- self.log_signal.emit("⚠️ Requesting cancellation of download process...")
- self.cancellation_event.set()
+ if self._is_download_active() and hasattr(self.download_thread, 'cancel'):
+ self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
+ self.download_thread.cancel()
+ else:
+ # Fallback for other download types
+ self.cancellation_event.set()
# Update UI to "Cancelling" state
self.pause_btn.setEnabled(False)
@@ -5064,6 +5306,10 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" Signaling Erome download thread to cancel.")
self.download_thread.cancel()
+ if isinstance(self.download_thread, Hentai2readDownloadThread):
+ self.log_signal.emit(" Signaling Hentai2Read download thread to cancel.")
+ self.download_thread.cancel()
+
def _get_domain_for_service(self, service_name: str) -> str:
"""Determines the base domain for a given service."""
if not isinstance(service_name, str):
@@ -5205,13 +5451,74 @@ class DownloaderApp (QWidget ):
self.retryable_failed_files_info.clear()
self.is_fetcher_thread_running = False
+
+ # --- This is where the post-download action is triggered ---
+ if not cancelled_by_user and not self.is_processing_favorites_queue:
+ self._execute_post_download_action()
self.set_ui_enabled(True)
self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False
self.active_update_profile = None
finally:
- pass
+ self.is_finishing = False
+ self.finish_lock.release()
+
+ def _execute_post_download_action(self):
+ """Checks the settings and performs the chosen action after downloads complete."""
+ action = self.settings.value(POST_DOWNLOAD_ACTION_KEY, "off")
+
+ if action == "off":
+ return
+
+ elif action == "notify":
+ QApplication.beep()
+ self.log_signal.emit("✅ Download complete! Notification sound played.")
+ return
+
+ # --- FIX: Ensure confirm_title is defined before it is used ---
+ confirm_title = self._tr("action_confirmation_title", "Action After Download")
+ confirm_text = ""
+
+ if action == "sleep":
+ confirm_text = self._tr("confirm_sleep_text", "All downloads are complete. The computer will now go to sleep.")
+ elif action == "shutdown":
+ confirm_text = self._tr("confirm_shutdown_text", "All downloads are complete. The computer will now shut down.")
+
+ dialog = CountdownMessageBox(
+ title=confirm_title,
+ text=confirm_text,
+ countdown_seconds=10,
+ parent_app=self,
+ parent=self
+ )
+
+ if dialog.exec_() == QDialog.Accepted:
+ # The rest of the logic only runs if the dialog is accepted (by click or timeout)
+ self.log_signal.emit(f"ℹ️ Performing post-download action: {action.capitalize()}")
+ try:
+ if sys.platform == "win32":
+ if action == "sleep":
+ os.system("powercfg -hibernate off")
+ os.system("rundll32.exe powrprof.dll,SetSuspendState 0,1,0")
+ os.system("powercfg -hibernate on")
+ elif action == "shutdown":
+ os.system("shutdown /s /t 1")
+ elif sys.platform == "darwin": # macOS
+ if action == "sleep":
+ os.system("pmset sleepnow")
+ elif action == "shutdown":
+ os.system("osascript -e 'tell app \"System Events\" to shut down'")
+ else: # Linux
+ if action == "sleep":
+ os.system("systemctl suspend")
+ elif action == "shutdown":
+ os.system("systemctl poweroff")
+ except Exception as e:
+ self.log_signal.emit(f"❌ Failed to execute post-download action '{action}': {e}")
+ else:
+ # This block runs if the user clicks "No"
+ self.log_signal.emit(f"ℹ️ Post-download '{action}' cancelled by user.")
def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is checked."""
@@ -6178,6 +6485,190 @@ class DownloaderApp (QWidget ):
# Use a QTimer to avoid deep recursion and correctly move to the next item.
QTimer.singleShot(100, self._process_next_favorite_download)
+class DiscordDownloadThread(QThread):
+ """A dedicated QThread for handling all official Discord downloads."""
+ progress_signal = pyqtSignal(str)
+ progress_label_signal = pyqtSignal(str)
+ finished_signal = pyqtSignal(int, int, bool, list)
+
+ def __init__(self, mode, session, token, output_dir, server_id, channel_id, url, limit=None, parent=None):
+ super().__init__(parent)
+ self.mode = mode
+ self.session = session
+ self.token = token
+ self.output_dir = output_dir
+ self.server_id = server_id
+ self.channel_id = channel_id
+ self.api_url = url
+ self.message_limit = limit
+
+ self.is_cancelled = False
+ self.is_paused = False
+
+ def run(self):
+ if self.mode == 'pdf':
+ self._run_pdf_creation()
+ else:
+ self._run_file_download()
+
+ def cancel(self):
+ self.progress_signal.emit(" Cancellation signal received by Discord thread.")
+ self.is_cancelled = True
+
+ def pause(self):
+ self.progress_signal.emit(" Pausing Discord download...")
+ self.is_paused = True
+
+ def resume(self):
+ self.progress_signal.emit(" Resuming Discord download...")
+ self.is_paused = False
+
+ def _check_events(self):
+ if self.is_cancelled:
+ return True
+ while self.is_paused:
+ time.sleep(0.5)
+ if self.is_cancelled:
+ return True
+ return False
+
+ def _fetch_all_messages(self):
+ all_messages = []
+ last_message_id = None
+ headers = {'Authorization': self.token, 'User-Agent': USERAGENT_FIREFOX}
+
+ while True:
+ if self._check_events(): break
+
+ endpoint = f"/channels/{self.channel_id}/messages?limit=100"
+ if last_message_id:
+ endpoint += f"&before={last_message_id}"
+
+ try:
+ # This is a blocking call, but it has a timeout
+ resp = self.session.get(f"https://discord.com/api/v10{endpoint}", headers=headers, timeout=30)
+ resp.raise_for_status()
+ message_batch = resp.json()
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Error fetching message batch: {e}")
+ break
+
+ if not message_batch:
+ break
+
+ all_messages.extend(message_batch)
+
+ if self.message_limit and len(all_messages) >= self.message_limit:
+ self.progress_signal.emit(f" Reached message limit of {self.message_limit}. Halting fetch.")
+ all_messages = all_messages[:self.message_limit]
+ break
+
+ last_message_id = message_batch[-1]['id']
+ self.progress_label_signal.emit(f"Fetched {len(all_messages)} messages...")
+ time.sleep(1) # API Rate Limiting
+
+ return all_messages
+
+ def _run_pdf_creation(self):
+ # ... (This method remains the same as the previous version)
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Discord PDF export for: {self.api_url}")
+ self.progress_label_signal.emit("Fetching messages...")
+
+ all_messages = self._fetch_all_messages()
+
+ if self.is_cancelled:
+ self.finished_signal.emit(0, 0, True, [])
+ return
+
+ self.progress_label_signal.emit(f"Collected {len(all_messages)} total messages. Generating PDF...")
+ all_messages.reverse()
+
+ base_path = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+ font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
+ output_filepath = os.path.join(self.output_dir, f"discord_{self.server_id}_{self.channel_id or 'server'}.pdf")
+
+ # The PDF generator itself now also checks for events
+ success = create_pdf_from_discord_messages(
+ all_messages, self.server_id, self.channel_id,
+ output_filepath, font_path, logger=self.progress_signal.emit,
+ cancellation_event=self, pause_event=self
+ )
+
+ if success:
+ self.progress_label_signal.emit(f"✅ PDF export complete!")
+ elif not self.is_cancelled:
+ self.progress_label_signal.emit(f"❌ PDF export failed. Check log for details.")
+
+ self.finished_signal.emit(0, len(all_messages), self.is_cancelled, [])
+
+ def _run_file_download(self):
+ # ... (This method remains the same as the previous version)
+ download_count = 0
+ skip_count = 0
+ try:
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Discord download for channel: {self.channel_id}")
+ self.progress_label_signal.emit("Fetching messages...")
+ all_messages = self._fetch_all_messages()
+
+ if self.is_cancelled:
+ self.finished_signal.emit(0, 0, True, [])
+ return
+
+ self.progress_label_signal.emit(f"Collected {len(all_messages)} messages. Starting downloads...")
+ total_attachments = sum(len(m.get('attachments', [])) for m in all_messages)
+
+ for message in reversed(all_messages):
+ if self._check_events(): break
+ for attachment in message.get('attachments', []):
+ if self._check_events(): break
+
+ file_url = attachment['url']
+ original_filename = attachment['filename']
+ filepath = os.path.join(self.output_dir, original_filename)
+ filename_to_use = original_filename
+
+ counter = 1
+ base_name, extension = os.path.splitext(original_filename)
+ while os.path.exists(filepath):
+ filename_to_use = f"{base_name} ({counter}){extension}"
+ filepath = os.path.join(self.output_dir, filename_to_use)
+ counter += 1
+
+ if filename_to_use != original_filename:
+ self.progress_signal.emit(f" -> Duplicate name '{original_filename}'. Saving as '{filename_to_use}'.")
+
+ try:
+ self.progress_signal.emit(f" Downloading ({download_count+1}/{total_attachments}): '{filename_to_use}'...")
+ response = requests.get(file_url, stream=True, timeout=60)
+ response.raise_for_status()
+
+ download_cancelled_mid_file = False
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self._check_events():
+ download_cancelled_mid_file = True
+ break
+ f.write(chunk)
+
+ if download_cancelled_mid_file:
+ self.progress_signal.emit(f" Download cancelled for '{filename_to_use}'. Deleting partial file.")
+ if os.path.exists(filepath):
+ os.remove(filepath)
+ continue
+
+ download_count += 1
+ except Exception as e:
+ self.progress_signal.emit(f" ❌ Failed to download '{filename_to_use}': {e}")
+ skip_count += 1
+ finally:
+ self.finished_signal.emit(download_count, skip_count, self.is_cancelled, [])
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Discord thread.")
+
class Saint2DownloadThread(QThread):
"""A dedicated QThread for handling saint2.su downloads."""
progress_signal = pyqtSignal(str)
@@ -6497,6 +6988,159 @@ class BunkrDownloadThread(QThread):
self.is_cancelled = True
self.progress_signal.emit(" Cancellation signal received by Bunkr thread.")
+class Hentai2readDownloadThread(QThread):
+ """
+ A dedicated QThread for Hentai2Read that uses a two-phase process:
+ 1. Fetch Phase: Scans all chapters to get total image count.
+ 2. Download Phase: Downloads all found images with overall progress.
+ """
+ progress_signal = pyqtSignal(str)
+ file_progress_signal = pyqtSignal(str, object)
+ finished_signal = pyqtSignal(int, int, bool)
+ overall_progress_signal = pyqtSignal(int, int)
+
+ def __init__(self, base_url, manga_slug, chapter_num, output_dir, pause_event, parent=None):
+ super().__init__(parent)
+ self.base_url = base_url
+ self.manga_slug = manga_slug
+ self.start_chapter = int(chapter_num) if chapter_num else 1
+ self.output_dir = output_dir
+ self.pause_event = pause_event
+ self.is_cancelled = False
+ # Store the original chapter number to detect single-chapter mode
+ self.original_chapter_num = chapter_num
+
+ def _check_pause(self):
+ if self.is_cancelled: return True
+ if self.pause_event and self.pause_event.is_set():
+ self.progress_signal.emit(" Download paused...")
+ while self.pause_event.is_set():
+ if self.is_cancelled: return True
+ time.sleep(0.5)
+ self.progress_signal.emit(" Download resumed.")
+ return self.is_cancelled
+
+ def run(self):
+ # --- SETUP ---
+ is_single_chapter_mode = self.original_chapter_num is not None
+
+ self.progress_signal.emit("=" * 40)
+ self.progress_signal.emit(f"🚀 Starting Hentai2Read Download for: {self.manga_slug}")
+
+ session = cloudscraper.create_scraper(
+ browser={'browser': 'firefox', 'platform': 'windows', 'desktop': True}
+ )
+
+ # --- PHASE 1: FETCH METADATA FOR ALL CHAPTERS ---
+ self.progress_signal.emit("--- Phase 1: Fetching metadata for all chapters... ---")
+ all_chapters_to_download = []
+ chapter_counter = self.start_chapter
+
+ while True:
+ if self._check_pause():
+ self.finished_signal.emit(0, 0, True)
+ return
+
+ chapter_url = f"{self.base_url}/{self.manga_slug}/{chapter_counter}/"
+ album_name, files_to_download = fetch_hentai2read_data(chapter_url, self.progress_signal.emit, session)
+
+ if not files_to_download:
+ break # End of series found
+
+ all_chapters_to_download.append({
+ 'album_name': album_name,
+ 'files': files_to_download,
+ 'chapter_num': chapter_counter,
+ 'chapter_url': chapter_url
+ })
+
+ if is_single_chapter_mode:
+ break # If user specified one chapter, only fetch that one
+ chapter_counter += 1
+
+ if self._check_pause():
+ self.finished_signal.emit(0, 0, True)
+ return
+
+ # --- PHASE 2: CALCULATE TOTALS & START DOWNLOAD ---
+ if not all_chapters_to_download:
+ self.progress_signal.emit("❌ No downloadable chapters found for this series.")
+ self.finished_signal.emit(0, 0, self.is_cancelled)
+ return
+
+ total_images = sum(len(chap['files']) for chap in all_chapters_to_download)
+ self.progress_signal.emit(f"✅ Fetch complete. Found {len(all_chapters_to_download)} chapter(s) with a total of {total_images} images.")
+ self.progress_signal.emit("--- Phase 2: Starting image downloads... ---")
+
+ self.overall_progress_signal.emit(total_images, 0)
+
+ grand_total_dl = 0
+ grand_total_skip = 0
+ images_processed = 0
+
+ for chapter_data in all_chapters_to_download:
+ if self._check_pause(): break
+
+ chapter_album_name = chapter_data['album_name']
+ self.progress_signal.emit("-" * 40)
+ self.progress_signal.emit(f"Downloading Chapter {chapter_data['chapter_num']}: '{chapter_album_name}'")
+
+ series_folder_name = clean_folder_name(chapter_album_name.split(' Chapter')[0])
+ chapter_folder_name = clean_folder_name(chapter_album_name)
+ final_save_path = os.path.join(self.output_dir, series_folder_name, chapter_folder_name)
+ os.makedirs(final_save_path, exist_ok=True)
+
+ for file_data in chapter_data['files']:
+ if self._check_pause(): break
+ images_processed += 1
+
+ filename = file_data.get('filename')
+ filepath = os.path.join(final_save_path, filename)
+
+ if os.path.exists(filepath):
+ self.progress_signal.emit(f" -> Skip ({images_processed}/{total_images}): '{filename}' already exists.")
+ grand_total_skip += 1
+ continue
+
+ self.progress_signal.emit(f" Downloading ({images_processed}/{total_images}): '{filename}'...")
+
+ download_successful = False
+ for attempt in range(3):
+ if self._check_pause(): break
+ try:
+ headers = {'Referer': chapter_data['chapter_url']}
+ response = session.get(file_data.get('url'), stream=True, timeout=60, headers=headers)
+ response.raise_for_status()
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if self._check_pause(): break
+ f.write(chunk)
+ if not self._check_pause():
+ download_successful = True
+ break
+ except (requests.exceptions.RequestException, ConnectionResetError):
+ if attempt < 2: time.sleep(2 * (attempt + 1))
+
+ if self._check_pause(): break
+ if download_successful:
+ grand_total_dl += 1
+ else:
+ self.progress_signal.emit(f" ❌ Download failed for '{filename}' after 3 attempts. Skipping.")
+ if os.path.exists(filepath): os.remove(filepath)
+ grand_total_skip += 1
+ self.overall_progress_signal.emit(total_images, images_processed)
+ time.sleep(random.uniform(0.2, 0.7))
+
+ if not is_single_chapter_mode:
+ time.sleep(random.uniform(1.5, 4.0))
+
+ self.file_progress_signal.emit("", None)
+ self.finished_signal.emit(grand_total_dl, grand_total_skip, self.is_cancelled)
+
+ def cancel(self):
+ self.is_cancelled = True
+ self.progress_signal.emit(" Cancellation signal received by Hentai2Read thread.")
+
class ExternalLinkDownloadThread (QThread ):
"""A QThread to handle downloading multiple external links sequentially."""
progress_signal =pyqtSignal (str )
diff --git a/src/utils/network_utils.py b/src/utils/network_utils.py
index bf85d9b..76b17ec 100644
--- a/src/utils/network_utils.py
+++ b/src/utils/network_utils.py
@@ -138,22 +138,10 @@ def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_coo
return None
-# In src/utils/network_utils.py
-
def extract_post_info(url_string):
"""
Parses a URL string to extract the service, user ID, and post ID.
- UPDATED to support Discord, Bunkr, and nhentai URLs.
-
- Args:
- url_string (str): The URL to parse.
-
- Returns:
- tuple: A tuple containing (service, id1, id2).
- For posts: (service, user_id, post_id).
- For Discord: ('discord', server_id, channel_id).
- For Bunkr: ('bunkr', full_url, None).
- For nhentai: ('nhentai', gallery_id, None).
+ UPDATED to support Hentai2Read series and chapters.
"""
if not isinstance(url_string, str) or not url_string.strip():
return None, None, None
@@ -171,6 +159,18 @@ def extract_post_info(url_string):
nhentai_match = re.search(r'nhentai\.net/g/(\d+)', stripped_url)
if nhentai_match:
return 'nhentai', nhentai_match.group(1), None
+
+ # --- Hentai2Read Check (Updated) ---
+ # This regex now captures the manga slug (id1) and optionally the chapter number (id2)
+ hentai2read_match = re.search(r'hentai2read\.com/([^/]+)(?:/(\d+))?/?', stripped_url)
+ if hentai2read_match:
+ manga_slug, chapter_num = hentai2read_match.groups()
+ return 'hentai2read', manga_slug, chapter_num # chapter_num will be None for series URLs
+
+ discord_channel_match = re.search(r'discord\.com/channels/(@me|\d+)/(\d+)', stripped_url)
+ if discord_channel_match:
+ server_id, channel_id = discord_channel_match.groups()
+ return 'discord', server_id, channel_id
# --- Kemono/Coomer/Discord Parsing ---
try:
diff --git a/src/utils/resolution.py b/src/utils/resolution.py
index 85925e5..8d5963f 100644
--- a/src/utils/resolution.py
+++ b/src/utils/resolution.py
@@ -284,7 +284,7 @@ def setup_ui(main_app):
advanced_row2_layout.addLayout(multithreading_layout)
main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
advanced_row2_layout.addWidget(main_app.external_links_checkbox)
- main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
+ main_app.manga_mode_checkbox = QCheckBox("Renaming Mode")
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout)
@@ -391,10 +391,23 @@ def setup_ui(main_app):
main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button)
+
+ discord_controls_layout = QHBoxLayout()
+
main_app.discord_scope_toggle_button = QPushButton("Scope: Files")
main_app.discord_scope_toggle_button.setVisible(False) # Hidden by default
- main_app.discord_scope_toggle_button.setFixedWidth(int(140 * scale))
- log_title_layout.addWidget(main_app.discord_scope_toggle_button)
+ discord_controls_layout.addWidget(main_app.discord_scope_toggle_button)
+
+ main_app.discord_message_limit_input = QLineEdit(main_app)
+ main_app.discord_message_limit_input.setPlaceholderText("Msg Limit")
+ main_app.discord_message_limit_input.setToolTip("Optional: Limit the number of recent messages to process.")
+ main_app.discord_message_limit_input.setValidator(QIntValidator(1, 9999999, main_app))
+ main_app.discord_message_limit_input.setFixedWidth(int(80 * scale))
+ main_app.discord_message_limit_input.setVisible(False) # Hide it by default
+ discord_controls_layout.addWidget(main_app.discord_message_limit_input)
+
+ log_title_layout.addLayout(discord_controls_layout)
+
main_app.manga_rename_toggle_button = QPushButton()
main_app.manga_rename_toggle_button.setVisible(False)
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))