17 Commits

Author SHA1 Message Date
Yuvi9587
0316813792 Delete dist directory 2025-05-26 13:55:54 +05:30
Yuvi9587
d201a5396c Delete build/Kemono Downloader directory 2025-05-26 13:55:25 +05:30
Yuvi9587
86f9396b6c Commit 2025-05-26 13:52:34 +05:30
Yuvi9587
0fb4bb3cb0 Commit 2025-05-26 13:52:07 +05:30
Yuvi9587
1528d7ce25 Update Read.png 2025-05-26 09:54:26 +05:30
Yuvi9587
4e7eeb7989 Commit 2025-05-26 09:52:06 +05:30
Yuvi9587
7f2976a4f4 Commit 2025-05-26 09:48:00 +05:30
Yuvi9587
8928cb92da readme.md 2025-05-26 01:39:39 +05:30
Yuvi9587
a181b76124 Update main.py 2025-05-25 17:18:11 +05:30
Yuvi9587
8f085a8f63 Commit 2025-05-25 21:52:04 +05:30
Yuvi9587
93a997351b Update readme.md 2025-05-25 21:22:47 +05:30
Yuvi9587
b3af6c1c15 Commit 2025-05-25 21:21:00 +05:30
Yuvi9587
4a65263f7d Commit 2025-05-25 19:49:17 +05:30
Yuvi9587
1091b5b9b4 Commit 2025-05-25 19:48:08 +05:30
Yuvi9587
f6b3ff2f5c Update main.py 2025-05-25 11:36:35 +05:30
Yuvi9587
b399bdf5cf readme.md 2025-05-25 16:54:35 +05:30
Yuvi9587
9ace161bc8 Update downloader_utils.py 2025-05-25 11:22:04 +05:30
4 changed files with 584 additions and 76 deletions

BIN
Read.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -31,6 +31,7 @@ from io import BytesIO
STYLE_POST_TITLE = "post_title" STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name" STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based" # For manga date-based sequential naming STYLE_DATE_BASED = "date_based" # For manga date-based sequential naming
MANGA_DATE_PREFIX_DEFAULT = "" # Default for the new prefix
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # For manga post title + global counter STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # For manga post title + global counter
SKIP_SCOPE_FILES = "files" SKIP_SCOPE_FILES = "files"
@@ -51,6 +52,9 @@ KNOWN_NAMES = [] # This will now store dicts: {'name': str, 'is_group': bool, 'a
MIN_SIZE_FOR_MULTIPART_DOWNLOAD = 10 * 1024 * 1024 # 10 MB - Stays the same MIN_SIZE_FOR_MULTIPART_DOWNLOAD = 10 * 1024 * 1024 # 10 MB - Stays the same
MAX_PARTS_FOR_MULTIPART_DOWNLOAD = 15 # Max concurrent connections for a single file MAX_PARTS_FOR_MULTIPART_DOWNLOAD = 15 # Max concurrent connections for a single file
# Max length for a single filename or folder name component to ensure cross-OS compatibility
# Windows MAX_PATH is 260 for the full path. Individual components are usually shorter.
MAX_FILENAME_COMPONENT_LENGTH = 150
IMAGE_EXTENSIONS = { IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
@@ -63,6 +67,11 @@ VIDEO_EXTENSIONS = {
ARCHIVE_EXTENSIONS = { ARCHIVE_EXTENSIONS = {
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2' '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'
} }
AUDIO_EXTENSIONS = {
'.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
'.aiff', '.ape', '.mid', '.midi'
}
def parse_cookie_string(cookie_string): def parse_cookie_string(cookie_string):
"""Parses a 'name=value; name2=value2' cookie string into a dict.""" """Parses a 'name=value; name2=value2' cookie string into a dict."""
cookies = {} cookies = {}
@@ -131,18 +140,46 @@ def clean_folder_name(name):
if not cleaned: # If empty after initial cleaning if not cleaned: # If empty after initial cleaning
return "untitled_folder" return "untitled_folder"
# Truncate if too long
if len(cleaned) > MAX_FILENAME_COMPONENT_LENGTH:
cleaned = cleaned[:MAX_FILENAME_COMPONENT_LENGTH]
# After truncation, it's possible a new trailing space/dot is at the end
# or an existing one remains. So, strip them using the loop below.
# Strip trailing dots/spaces (original logic, now applied to potentially truncated name)
temp_name = cleaned temp_name = cleaned
while len(temp_name) > 0 and (temp_name.endswith('.') or temp_name.endswith(' ')): while len(temp_name) > 0 and (temp_name.endswith('.') or temp_name.endswith(' ')):
temp_name = temp_name[:-1] temp_name = temp_name[:-1]
return temp_name if temp_name else "untitled_folder" return temp_name if temp_name else "untitled_folder"
def clean_filename(name): def clean_filename(name):
if not isinstance(name, str): name = str(name) if not isinstance(name, str): name = str(name)
cleaned = re.sub(r'[^\w\s\-\_\.\(\)]', '', name) cleaned = re.sub(r'[^\w\s\-\_\.\(\)]', '', name)
cleaned = cleaned.strip() cleaned = cleaned.strip() # Remove leading/trailing spaces first
cleaned = re.sub(r'\s+', '_', cleaned) cleaned = re.sub(r'\s+', ' ', cleaned) # Replace multiple internal spaces with a single space
return cleaned if cleaned else "untitled_file"
if not cleaned: return "untitled_file"
base_name, ext = os.path.splitext(cleaned)
# Calculate max length for base_name, reserving space for the extension
max_base_len = MAX_FILENAME_COMPONENT_LENGTH - len(ext)
if len(base_name) > max_base_len:
if max_base_len > 0: # If there's space for at least some of the base name
base_name = base_name[:max_base_len]
else: # No space for base name (extension is too long or fills the entire allowed space)
# In this case, we have to truncate the original 'cleaned' string,
# which might cut into the extension, but it's necessary to meet the length.
return cleaned[:MAX_FILENAME_COMPONENT_LENGTH] if cleaned else "untitled_file"
final_name = base_name + ext
# Ensure the final reconstructed name isn't empty (e.g. if base_name became empty and ext was also empty)
return final_name if final_name else "untitled_file"
def strip_html_tags(html_text): def strip_html_tags(html_text):
if not html_text: return "" if not html_text: return ""
@@ -218,6 +255,12 @@ def is_archive(filename):
_, ext = os.path.splitext(filename) _, ext = os.path.splitext(filename)
return ext.lower() in ARCHIVE_EXTENSIONS return ext.lower() in ARCHIVE_EXTENSIONS
def is_audio(filename):
if not filename: return False
_, ext = os.path.splitext(filename)
return ext.lower() in AUDIO_EXTENSIONS
def is_post_url(url): def is_post_url(url):
if not isinstance(url, str): return False if not isinstance(url, str): return False
@@ -604,7 +647,9 @@ class PostProcessorWorker:
use_cookie=False, # Added missing parameter use_cookie=False, # Added missing parameter
selected_cookie_file=None, # Added missing parameter selected_cookie_file=None, # Added missing parameter
app_base_dir=None, # New parameter for app's base directory app_base_dir=None, # New parameter for app's base directory
manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, # New parameter for date-based prefix
manga_date_file_counter_ref=None, # New parameter for date-based manga naming manga_date_file_counter_ref=None, # New parameter for date-based manga naming
scan_content_for_images=False, # New flag for scanning HTML content
manga_global_file_counter_ref=None, # New parameter for global numbering manga_global_file_counter_ref=None, # New parameter for global numbering
): # type: ignore ): # type: ignore
self.post = post_data self.post = post_data
@@ -652,8 +697,10 @@ class PostProcessorWorker:
self.selected_cookie_file = selected_cookie_file # Store selected cookie file path self.selected_cookie_file = selected_cookie_file # Store selected cookie file path
self.app_base_dir = app_base_dir # Store app base dir self.app_base_dir = app_base_dir # Store app base dir
self.cookie_text = cookie_text # Store cookie text self.cookie_text = cookie_text # Store cookie text
self.manga_date_prefix = manga_date_prefix # Store the prefix
self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store global counter self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store global counter
self.use_cookie = use_cookie # Store cookie setting self.use_cookie = use_cookie # Store cookie setting
self.scan_content_for_images = scan_content_for_images # Store new flag
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found.") self.logger("⚠️ Image compression disabled: Pillow library not found.")
@@ -734,6 +781,14 @@ class PostProcessorWorker:
if self.manga_mode_active: # Note: duplicate_file_mode is overridden to "Delete" in main.py if manga_mode is on if self.manga_mode_active: # Note: duplicate_file_mode is overridden to "Delete" in main.py if manga_mode is on
if self.manga_filename_style == STYLE_ORIGINAL_NAME: if self.manga_filename_style == STYLE_ORIGINAL_NAME:
filename_to_save_in_main_path = clean_filename(api_original_filename) filename_to_save_in_main_path = clean_filename(api_original_filename)
# Apply prefix if provided for Original Name style
if self.manga_date_prefix and self.manga_date_prefix.strip():
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
if cleaned_prefix:
filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}"
else:
self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.")
was_original_name_kept_flag = True was_original_name_kept_flag = True
elif self.manga_filename_style == STYLE_POST_TITLE: elif self.manga_filename_style == STYLE_POST_TITLE:
if post_title and post_title.strip(): if post_title and post_title.strip():
@@ -742,8 +797,8 @@ class PostProcessorWorker:
if file_index_in_post == 0: if file_index_in_post == 0:
filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}" filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}"
else: else:
filename_to_save_in_main_path = clean_filename(api_original_filename) filename_to_save_in_main_path = f"{cleaned_post_title_base}_{file_index_in_post}{original_ext}"
was_original_name_kept_flag = True was_original_name_kept_flag = False # Name is derived, not original
else: else:
filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}" filename_to_save_in_main_path = f"{cleaned_post_title_base}{original_ext}"
else: else:
@@ -759,7 +814,15 @@ class PostProcessorWorker:
counter_val_for_filename = manga_date_file_counter_ref[0] counter_val_for_filename = manga_date_file_counter_ref[0]
manga_date_file_counter_ref[0] += 1 manga_date_file_counter_ref[0] += 1
filename_to_save_in_main_path = f"{counter_val_for_filename:03d}{original_ext}" base_numbered_name = f"{counter_val_for_filename:03d}"
if self.manga_date_prefix and self.manga_date_prefix.strip():
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
if cleaned_prefix: # Ensure prefix is not empty after cleaning
filename_to_save_in_main_path = f"{cleaned_prefix} {base_numbered_name}{original_ext}"
else: # Prefix became empty after cleaning
filename_to_save_in_main_path = f"{base_numbered_name}{original_ext}"; self.logger(f"⚠️ Manga Date Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using number only.")
else: # No prefix provided
filename_to_save_in_main_path = f"{base_numbered_name}{original_ext}"
else: else:
self.logger(f"⚠️ Manga Date Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_date_file_counter_ref}") self.logger(f"⚠️ Manga Date Mode: Counter ref not provided or malformed for '{api_original_filename}'. Using original. Ref: {manga_date_file_counter_ref}")
filename_to_save_in_main_path = clean_filename(api_original_filename) filename_to_save_in_main_path = clean_filename(api_original_filename)
@@ -796,8 +859,10 @@ class PostProcessorWorker:
if not word_to_remove: continue if not word_to_remove: continue
pattern = re.compile(re.escape(word_to_remove), re.IGNORECASE) pattern = re.compile(re.escape(word_to_remove), re.IGNORECASE)
modified_base_name = pattern.sub("", modified_base_name) modified_base_name = pattern.sub("", modified_base_name)
modified_base_name = re.sub(r'[_.\s-]+', '_', modified_base_name) # After removals, normalize all seps (underscore, dot, multiple spaces, hyphen) to a single space, then strip.
modified_base_name = modified_base_name.strip('_') modified_base_name = re.sub(r'[_.\s-]+', ' ', modified_base_name) # Convert all separators to spaces
modified_base_name = re.sub(r'\s+', ' ', modified_base_name) # Condense multiple spaces to one
modified_base_name = modified_base_name.strip() # Remove leading/trailing spaces
if modified_base_name and modified_base_name != ext_for_removal.lstrip('.'): if modified_base_name and modified_base_name != ext_for_removal.lstrip('.'):
filename_to_save_in_main_path = modified_base_name + ext_for_removal filename_to_save_in_main_path = modified_base_name + ext_for_removal
else: else:
@@ -807,6 +872,7 @@ class PostProcessorWorker:
is_img_type = is_image(api_original_filename) is_img_type = is_image(api_original_filename)
is_vid_type = is_video(api_original_filename) is_vid_type = is_video(api_original_filename)
is_archive_type = is_archive(api_original_filename) is_archive_type = is_archive(api_original_filename)
is_audio_type = is_audio(api_original_filename)
if self.filter_mode == 'archive': if self.filter_mode == 'archive':
if not is_archive_type: if not is_archive_type:
@@ -820,6 +886,10 @@ class PostProcessorWorker:
if not is_vid_type: if not is_vid_type:
self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Video).") self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Video).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
elif self.filter_mode == 'audio': # New audio filter mode
if not is_audio_type:
self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).")
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
if self.skip_zip and is_zip(api_original_filename): if self.skip_zip and is_zip(api_original_filename):
self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).") self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).")
@@ -1318,14 +1388,14 @@ class PostProcessorWorker:
if original_api_name: if original_api_name:
all_files_from_post_api.append({ all_files_from_post_api.append({
'url': f"https://{api_file_domain}{file_path}" if file_path.startswith('/') else f"https://{api_file_domain}/data/{file_path}", 'url': f"https://{api_file_domain}{file_path}" if file_path.startswith('/') else f"https://{api_file_domain}/data/{file_path}",
'name': original_api_name, 'name': original_api_name, # This is the cleaned/API provided name
'_original_name_for_log': original_api_name, '_original_name_for_log': original_api_name,
'_is_thumbnail': self.download_thumbnails and is_image(original_api_name) '_is_thumbnail': is_image(original_api_name) # Mark if it's an image from API
}) })
else: self.logger(f" ⚠️ Skipping main file for post {post_id}: Missing name (Path: {file_path})") else: self.logger(f" ⚠️ Skipping main file for post {post_id}: Missing name (Path: {file_path})")
for idx, att_info in enumerate(post_attachments): for idx, att_info in enumerate(post_attachments):
if isinstance(att_info, dict) and att_info.get('path'): if isinstance(att_info, dict) and att_info.get('path'): # Ensure att_info is a dict
att_path = att_info['path'].lstrip('/') att_path = att_info['path'].lstrip('/')
original_api_att_name = att_info.get('name') or os.path.basename(att_path) original_api_att_name = att_info.get('name') or os.path.basename(att_path)
if original_api_att_name: if original_api_att_name:
@@ -1333,16 +1403,99 @@ class PostProcessorWorker:
'url': f"https://{api_file_domain}{att_path}" if att_path.startswith('/') else f"https://{api_file_domain}/data/{att_path}", 'url': f"https://{api_file_domain}{att_path}" if att_path.startswith('/') else f"https://{api_file_domain}/data/{att_path}",
'name': original_api_att_name, 'name': original_api_att_name,
'_original_name_for_log': original_api_att_name, '_original_name_for_log': original_api_att_name,
'_is_thumbnail': self.download_thumbnails and is_image(original_api_att_name) '_is_thumbnail': is_image(original_api_att_name) # Mark if it's an image from API
}) })
else: self.logger(f" ⚠️ Skipping attachment {idx+1} for post {post_id}: Missing name (Path: {att_path})") else: self.logger(f" ⚠️ Skipping attachment {idx+1} for post {post_id}: Missing name (Path: {att_path})")
else: self.logger(f" ⚠️ Skipping invalid attachment {idx+1} for post {post_id}: {str(att_info)[:100]}") else: self.logger(f" ⚠️ Skipping invalid attachment {idx+1} for post {post_id}: {str(att_info)[:100]}")
# --- New: Scan post content for additional image URLs if enabled ---
if self.scan_content_for_images and post_content_html and not self.extract_links_only: # This block was duplicated, ensure only one exists
self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...")
parsed_input_url = urlparse(self.api_url_input)
base_url_for_relative_paths = f"{parsed_input_url.scheme}://{parsed_input_url.netloc}"
img_ext_pattern = "|".join(ext.lstrip('.') for ext in IMAGE_EXTENSIONS)
# 1. Regex for direct absolute image URLs in text
direct_url_pattern_str = r"""(?i)\b(https?://[^\s"'<>\[\]\{\}\|\^\\^~\[\]`]+\.(?:""" + img_ext_pattern + r"""))\b"""
# 2. Regex for <img> tags (captures src content)
img_tag_src_pattern_str = r"""<img\s+[^>]*?src\s*=\s*["']([^"']+)["']"""
found_image_sources = set()
for direct_url_match in re.finditer(direct_url_pattern_str, post_content_html):
found_image_sources.add(direct_url_match.group(1))
for img_tag_match in re.finditer(img_tag_src_pattern_str, post_content_html, re.IGNORECASE):
src_attr = img_tag_match.group(1).strip()
src_attr = html.unescape(src_attr)
if not src_attr: continue
resolved_src_url = ""
if src_attr.startswith(('http://', 'https://')):
resolved_src_url = src_attr
elif src_attr.startswith('//'):
resolved_src_url = f"{parsed_input_url.scheme}:{src_attr}"
elif src_attr.startswith('/'):
resolved_src_url = f"{base_url_for_relative_paths}{src_attr}"
if resolved_src_url:
parsed_resolved_url = urlparse(resolved_src_url)
if any(parsed_resolved_url.path.lower().endswith(ext) for ext in IMAGE_EXTENSIONS):
found_image_sources.add(resolved_src_url)
if found_image_sources:
self.logger(f" Found {len(found_image_sources)} potential image URLs/sources in content.")
existing_urls_in_api_list = {f_info['url'] for f_info in all_files_from_post_api}
for found_url in found_image_sources: # Iterate over the unique, resolved URLs
if self.check_cancel(): break
if found_url in existing_urls_in_api_list:
self.logger(f" Skipping URL from content (already in API list or previously added from content): {found_url[:70]}...")
continue
try:
parsed_found_url = urlparse(found_url)
url_filename = os.path.basename(parsed_found_url.path)
if not url_filename or not is_image(url_filename):
self.logger(f" Skipping URL from content (no filename part or not an image extension): {found_url[:70]}...")
continue
self.logger(f" Adding image from content: {url_filename} (URL: {found_url[:70]}...)")
all_files_from_post_api.append({
'url': found_url,
'name': url_filename,
'_original_name_for_log': url_filename,
'_is_thumbnail': False, # Images from content are not API thumbnails
'_from_content_scan': True
})
existing_urls_in_api_list.add(found_url)
except Exception as e_url_parse:
self.logger(f" Error processing URL from content '{found_url[:70]}...': {e_url_parse}")
else:
self.logger(f" No additional image URLs found in post content scan for post {post_id}.")
# --- End of new content scanning logic ---
# --- Final filtering based on download_thumbnails and scan_content_for_images flags ---
if self.download_thumbnails: if self.download_thumbnails:
all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo['_is_thumbnail']] if self.scan_content_for_images:
if not all_files_from_post_api: # Both "Download Thumbnails Only" AND "Scan Content for Images" are checked.
self.logger(f" -> No image thumbnails found for post {post_id} in thumbnail-only mode.") # Prioritize images from content scan.
return 0, 0, [], [] self.logger(f" Mode: 'Download Thumbnails Only' + 'Scan Content for Images' active. Prioritizing images from content scan for post {post_id}.")
all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo.get('_from_content_scan')]
if not all_files_from_post_api:
self.logger(f" -> No images found via content scan for post {post_id} in this combined mode.")
return 0, 0, [], [] # No files to download for this post
else:
# Only "Download Thumbnails Only" is checked. Filter for API thumbnails.
self.logger(f" Mode: 'Download Thumbnails Only' active. Filtering for API thumbnails for post {post_id}.")
all_files_from_post_api = [finfo for finfo in all_files_from_post_api if finfo.get('_is_thumbnail')]
if not all_files_from_post_api:
self.logger(f" -> No API image thumbnails found for post {post_id} in thumbnail-only mode.")
return 0, 0, [], [] # No files to download for this post
# If self.download_thumbnails is False, all_files_from_post_api remains as is.
# It will contain all API files (images marked with _is_thumbnail: True, others False)
# and potentially content-scanned images (marked with _from_content_scan: True).
if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED: if self.manga_mode_active and self.manga_filename_style == STYLE_DATE_BASED:
def natural_sort_key_for_files(file_api_info): def natural_sort_key_for_files(file_api_info):
name = file_api_info.get('_original_name_for_log', '').lower() name = file_api_info.get('_original_name_for_log', '').lower()
@@ -1548,12 +1701,14 @@ class DownloadThread(QThread):
manga_filename_style=STYLE_POST_TITLE, manga_filename_style=STYLE_POST_TITLE,
char_filter_scope=CHAR_SCOPE_FILES, # manga_date_file_counter_ref removed from here char_filter_scope=CHAR_SCOPE_FILES, # manga_date_file_counter_ref removed from here
remove_from_filename_words_list=None, remove_from_filename_words_list=None,
manga_date_prefix=MANGA_DATE_PREFIX_DEFAULT, # New parameter
allow_multipart_download=True, allow_multipart_download=True,
selected_cookie_file=None, # New parameter for selected cookie file selected_cookie_file=None, # New parameter for selected cookie file
app_base_dir=None, # New parameter app_base_dir=None, # New parameter
manga_date_file_counter_ref=None, # New parameter manga_date_file_counter_ref=None, # New parameter
manga_global_file_counter_ref=None, # New parameter for global numbering manga_global_file_counter_ref=None, # New parameter for global numbering
use_cookie=False, # Added: Expected by main.py use_cookie=False, # Added: Expected by main.py
scan_content_for_images=False, # Added new flag
cookie_text="", # Added: Expected by main.py cookie_text="", # Added: Expected by main.py
): ):
super().__init__() super().__init__()
@@ -1597,12 +1752,14 @@ class DownloadThread(QThread):
self.manga_filename_style = manga_filename_style self.manga_filename_style = manga_filename_style
self.char_filter_scope = char_filter_scope self.char_filter_scope = char_filter_scope
self.remove_from_filename_words_list = remove_from_filename_words_list self.remove_from_filename_words_list = remove_from_filename_words_list
self.manga_date_prefix = manga_date_prefix # Store the prefix
self.allow_multipart_download = allow_multipart_download self.allow_multipart_download = allow_multipart_download
self.selected_cookie_file = selected_cookie_file # Store selected cookie file self.selected_cookie_file = selected_cookie_file # Store selected cookie file
self.app_base_dir = app_base_dir # Store app base dir self.app_base_dir = app_base_dir # Store app base dir
self.cookie_text = cookie_text # Store cookie text self.cookie_text = cookie_text # Store cookie text
self.use_cookie = use_cookie # Store cookie setting self.use_cookie = use_cookie # Store cookie setting
self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread self.manga_date_file_counter_ref = manga_date_file_counter_ref # Store for passing to worker by DownloadThread
self.scan_content_for_images = scan_content_for_images # Store new flag
self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store for global numbering self.manga_global_file_counter_ref = manga_global_file_counter_ref # Store for global numbering
if self.compress_images and Image is None: if self.compress_images and Image is None:
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).") self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
@@ -1726,6 +1883,7 @@ class DownloadThread(QThread):
skip_current_file_flag=self.skip_current_file_flag, skip_current_file_flag=self.skip_current_file_flag,
manga_mode_active=self.manga_mode_active, manga_mode_active=self.manga_mode_active,
manga_filename_style=self.manga_filename_style, manga_filename_style=self.manga_filename_style,
manga_date_prefix=self.manga_date_prefix, # Pass the prefix
char_filter_scope=self.char_filter_scope, char_filter_scope=self.char_filter_scope,
remove_from_filename_words_list=self.remove_from_filename_words_list, remove_from_filename_words_list=self.remove_from_filename_words_list,
allow_multipart_download=self.allow_multipart_download, allow_multipart_download=self.allow_multipart_download,
@@ -1735,6 +1893,7 @@ class DownloadThread(QThread):
manga_global_file_counter_ref=self.manga_global_file_counter_ref, # Pass the ref manga_global_file_counter_ref=self.manga_global_file_counter_ref, # Pass the ref
use_cookie=self.use_cookie, # Pass cookie setting to worker use_cookie=self.use_cookie, # Pass cookie setting to worker
manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref manga_date_file_counter_ref=current_manga_date_file_counter_ref, # Pass the calculated or passed-in ref
scan_content_for_images=self.scan_content_for_images, # Pass new flag
) )
try: try:
dl_count, skip_count, kept_originals_this_post, retryable_failures = post_processing_worker.process() dl_count, skip_count, kept_originals_this_post, retryable_failures = post_processing_worker.process()

388
main.py
View File

@@ -54,8 +54,10 @@ try:
CHAR_SCOPE_FILES, # Ensure this is imported CHAR_SCOPE_FILES, # Ensure this is imported
CHAR_SCOPE_BOTH, CHAR_SCOPE_BOTH,
CHAR_SCOPE_COMMENTS, CHAR_SCOPE_COMMENTS,
FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, # Import the new status FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER,
STYLE_DATE_BASED, # Import new manga style
STYLE_POST_TITLE_GLOBAL_NUMBERING # Import new manga style STYLE_POST_TITLE_GLOBAL_NUMBERING # Import new manga style
# IMAGE_EXTENSIONS will be used from downloader_utils directly
) )
print("Successfully imported names from downloader_utils.") print("Successfully imported names from downloader_utils.")
except ImportError as e: except ImportError as e:
@@ -88,6 +90,7 @@ except ImportError as e:
CHAR_SCOPE_BOTH = "both" CHAR_SCOPE_BOTH = "both"
CHAR_SCOPE_COMMENTS = "comments" CHAR_SCOPE_COMMENTS = "comments"
FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later" FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later"
STYLE_DATE_BASED = "date_based"
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # Mock for safety STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering" # Mock for safety
except Exception as e: except Exception as e:
@@ -112,7 +115,7 @@ HTML_PREFIX = "<!HTML!>"
CONFIG_ORGANIZATION_NAME = "KemonoDownloader" CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
CONFIG_APP_NAME_MAIN = "ApplicationSettings" CONFIG_APP_NAME_MAIN = "ApplicationSettings"
MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1" MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
STYLE_POST_TITLE = "post_title" STYLE_POST_TITLE = "post_title" # Already defined, but ensure it's STYLE_POST_TITLE
STYLE_ORIGINAL_NAME = "original_name" STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based" # New style for date-based naming STYLE_DATE_BASED = "date_based" # New style for date-based naming
STYLE_POST_TITLE_GLOBAL_NUMBERING = STYLE_POST_TITLE_GLOBAL_NUMBERING # Use imported or mocked STYLE_POST_TITLE_GLOBAL_NUMBERING = STYLE_POST_TITLE_GLOBAL_NUMBERING # Use imported or mocked
@@ -122,6 +125,7 @@ ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
USE_COOKIE_KEY = "useCookieV1" # New setting key USE_COOKIE_KEY = "useCookieV1" # New setting key
COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text COOKIE_TEXT_KEY = "cookieTextV1" # New setting key for cookie text
CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1" CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1"
SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1" # New setting key
CONFIRM_ADD_ALL_ACCEPTED = 1 CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2 CONFIRM_ADD_ALL_SKIP_ADDING = 2
@@ -233,6 +237,97 @@ class ConfirmAddAllDialog(QDialog):
return CONFIRM_ADD_ALL_SKIP_ADDING return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice return self.user_choice
class KnownNamesFilterDialog(QDialog):
"""A dialog to select names from Known.txt to add to the filter input."""
def __init__(self, known_names_list, parent=None):
super().__init__(parent)
self.setWindowTitle("Add Known Names to Filter")
self.setModal(True)
# Store the full list of known name objects. Each object is a dict.
# Sort them by the 'name' field for consistent display.
self.all_known_name_entries = sorted(known_names_list, key=lambda x: x['name'].lower())
self.selected_entries_to_return = []
main_layout = QVBoxLayout(self)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search names...")
self.search_input.textChanged.connect(self._filter_list_display)
main_layout.addWidget(self.search_input)
self.names_list_widget = QListWidget()
self._populate_list_widget() # Populate with all entries initially
main_layout.addWidget(self.names_list_widget)
# Buttons layout: Select All, Deselect All, Add, Cancel
buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton("Select All")
self.select_all_button.clicked.connect(self._select_all_items)
buttons_layout.addWidget(self.select_all_button) # Add to main buttons_layout
self.deselect_all_button = QPushButton("Deselect All")
self.deselect_all_button.clicked.connect(self._deselect_all_items)
buttons_layout.addWidget(self.deselect_all_button) # Add to main buttons_layout
buttons_layout.addStretch(1) # Stretch between Deselect All and Add Selected
self.add_button = QPushButton("Add Selected")
self.add_button.clicked.connect(self._accept_selection_action)
buttons_layout.addWidget(self.add_button)
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.reject)
buttons_layout.addWidget(self.cancel_button)
main_layout.addLayout(buttons_layout)
self.setMinimumWidth(350)
self.setMinimumHeight(400)
if parent and hasattr(parent, 'get_dark_theme'):
self.setStyleSheet(parent.get_dark_theme())
self.add_button.setDefault(True)
def _populate_list_widget(self, names_to_display=None):
self.names_list_widget.clear()
current_entries_source = names_to_display if names_to_display is not None else self.all_known_name_entries
for entry_obj in current_entries_source:
item = QListWidgetItem(entry_obj['name']) # Display the 'name' (folder name)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
item.setData(Qt.UserRole, entry_obj) # Store the full entry object
self.names_list_widget.addItem(item)
def _filter_list_display(self):
search_text = self.search_input.text().lower()
if not search_text:
self._populate_list_widget()
return
# Filter based on the 'name' field of each entry object
filtered_entries = [
entry_obj for entry_obj in self.all_known_name_entries if search_text in entry_obj['name'].lower()
]
self._populate_list_widget(filtered_entries)
def _accept_selection_action(self):
self.selected_entries_to_return = []
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if item.checkState() == Qt.Checked:
self.selected_entries_to_return.append(item.data(Qt.UserRole)) # Get the stored entry object
self.accept()
def _select_all_items(self):
"""Checks all items in the list widget."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Checked)
def _deselect_all_items(self):
"""Unchecks all items in the list widget."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
def get_selected_entries(self): # Renamed method
return self.selected_entries_to_return
class HelpGuideDialog(QDialog): class HelpGuideDialog(QDialog):
"""A multi-page dialog for displaying the feature guide.""" """A multi-page dialog for displaying the feature guide."""
def __init__(self, steps_data, parent=None): def __init__(self, steps_data, parent=None):
@@ -521,7 +616,8 @@ class TourDialog(QDialog):
" Enter character names, comma-separated (e.g., <i>Tifa, Aerith</i>). Group aliases for a combined folder name: <i>(alias1, alias2, alias3)</i> becomes folder 'alias1 alias2 alias3' (after cleaning). All names in the group are used as aliases for matching.<br>" " Enter character names, comma-separated (e.g., <i>Tifa, Aerith</i>). Group aliases for a combined folder name: <i>(alias1, alias2, alias3)</i> becomes folder 'alias1 alias2 alias3' (after cleaning). All names in the group are used as aliases for matching.<br>"
" The <b>'Filter: [Type]'</b> button (next to this input) cycles how this filter applies:" " The <b>'Filter: [Type]'</b> button (next to this input) cycles how this filter applies:"
" <ul><li><i>Filter: Files:</i> Checks individual filenames. A post is kept if any file matches; only matching files are downloaded. Folder naming uses the character from the matching filename (if 'Separate Folders' is on).</li><br>" " <ul><li><i>Filter: Files:</i> Checks individual filenames. A post is kept if any file matches; only matching files are downloaded. Folder naming uses the character from the matching filename (if 'Separate Folders' is on).</li><br>"
" <li><i>Filter: Title:</i> Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title.</li><br>" " <li><i>Filter: Title:</i> Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title.</li>"
" <li><b>⤵️ Add to Filter Button (Known Names):</b> Next to the 'Add' button for Known Names (see Step 5), this opens a popup. Select names from your <code>Known.txt</code> list via checkboxes (with a search bar) to quickly add them to the 'Filter by Character(s)' field. Grouped names like <code>(Boa, Hancock)</code> from Known.txt will be added as <code>(Boa, Hancock)~</code> to the filter.</li><br>" # Added new feature here
" <li><i>Filter: Both:</i> 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 title match, then file match.</li><br>" " <li><i>Filter: Both:</i> 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 title match, then file match.</li><br>"
" <li><i>Filter: Comments (Beta):</i> Checks filenames first. If a file matches, all files from 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 file match, then comment match.</li></ul>" " <li><i>Filter: Comments (Beta):</i> Checks filenames first. If a file matches, all files from 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 file match, then comment match.</li></ul>"
" This filter also influences folder naming if 'Separate Folders by Name/Title' is enabled.</li><br>" " This filter also influences folder naming if 'Separate Folders by Name/Title' is enabled.</li><br>"
@@ -537,6 +633,7 @@ class TourDialog(QDialog):
" <li><i>Images/GIFs:</i> Only common image formats and GIFs.</li><br>" " <li><i>Images/GIFs:</i> Only common image formats and GIFs.</li><br>"
" <li><i>Videos:</i> Only common video formats.</li><br>" " <li><i>Videos:</i> Only common video formats.</li><br>"
" <li><b><i>📦 Only Archives:</i></b> Exclusively downloads <b>.zip</b> and <b>.rar</b> files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show External Links' is also disabled.</li><br>" " <li><b><i>📦 Only Archives:</i></b> Exclusively downloads <b>.zip</b> and <b>.rar</b> files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show External Links' is also disabled.</li><br>"
" <li><i>🎧 Only Audio:</i> Only common audio formats (MP3, WAV, FLAC, etc.).</li><br>"
" <li><i>🔗 Only Links:</i> Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show External Links' are disabled.</li>" " <li><i>🔗 Only Links:</i> Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show External Links' are disabled.</li>"
" </ul></li>" " </ul></li>"
"</ul>" "</ul>"
@@ -584,12 +681,13 @@ class TourDialog(QDialog):
" <li>The 'Page Range' input is disabled as all posts are fetched.</li><br>" " <li>The 'Page Range' input is disabled as all posts are fetched.</li><br>"
" <li>A <b>filename style toggle button</b> (e.g., 'Name: Post Title') appears in the top-right of the log area when this mode is active for a creator feed. Click it to cycle through naming styles:" " <li>A <b>filename style toggle button</b> (e.g., 'Name: Post Title') appears in the top-right of the log area when this mode is active for a creator feed. Click it to cycle through naming styles:"
" <ul>" " <ul>"
" <li><b><i>Name: Post Title (Default):</i></b> The first file in a post is named after the post's title. Subsequent files in the same post keep original names.</li><br>" " <li><b><i>Name: Post Title (Default):</i></b> The first file in a post is named after the post's cleaned title (e.g., 'My Chapter 1.jpg'). Subsequent files within 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.</li><br>"
" <li><b><i>Name: Original File:</i></b> All files attempt to keep their original filenames.</li><br>" " <li><b><i>Name: Original File:</i></b> 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'.</li><br>"
" <li><b><i>Name: Date Based:</i></b> Files are named sequentially (001.ext, 002.ext, ...) based on post publication order. Multithreading for post processing is automatically disabled for this style.</li>" " <li><b><i>Name: Title+G.Num (Post Title + Global Numbering):</i></b> All files across all posts in the current download session are named sequentially using the post's cleaned title as a prefix, followed by a global counter. For example: 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.</li><br>"
" <li><b><i>Name: Date Based:</i></b> Files are named sequentially (001.ext, 002.ext, ...) based on post publication order. 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.</li>"
" </ul>" " </ul>"
" </li><br>" " </li><br>"
" <li>For best results with 'Name: Post Title' or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.</li>" " <li>For best results with '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.</li>"
" </ul></li><br>" " </ul></li><br>"
"<li><b>🎭 Known.txt for Smart Folder Organization:</b><br>" "<li><b>🎭 Known.txt for Smart Folder Organization:</b><br>"
" <code>Known.txt</code> (in the app's directory) allows fine-grained control over automatic folder organization when 'Separate Folders by Name/Title' is active." " <code>Known.txt</code> (in the app's directory) allows fine-grained control over automatic folder organization when 'Separate Folders by Name/Title' is active."
@@ -811,6 +909,12 @@ class DownloaderApp(QWidget):
self.prompt_mutex = QMutex() self.prompt_mutex = QMutex()
self._add_character_response = None self._add_character_response = None
# Store original tooltips for dynamic updates. Label changed, tooltip content remains valid.
self._original_scan_content_tooltip = ("If checked, the downloader will scan the HTML content of posts for image URLs (from <img> tags or direct links).\n"
"now This includes resolving relative paths from <img> tags to full URLs.\n"
"Relative paths in <img> tags (e.g., /data/image.jpg) will be resolved to full URLs.\n"
"Useful for cases where images are in the post description but not in the API's file/attachment list.")
self.downloaded_files = set() self.downloaded_files = set()
self.downloaded_files_lock = threading.Lock() self.downloaded_files_lock = threading.Lock()
self.downloaded_file_hashes = set() self.downloaded_file_hashes = set()
@@ -853,11 +957,12 @@ class DownloaderApp(QWidget):
self.char_filter_scope = self.settings.value(CHAR_FILTER_SCOPE_KEY, CHAR_SCOPE_FILES, type=str) # Default to Files self.char_filter_scope = self.settings.value(CHAR_FILTER_SCOPE_KEY, CHAR_SCOPE_FILES, type=str) # Default to Files
self.allow_multipart_download_setting = False self.allow_multipart_download_setting = False
self.use_cookie_setting = False # Always default to False on launch self.use_cookie_setting = False # Always default to False on launch
self.scan_content_images_setting = self.settings.value(SCAN_CONTENT_IMAGES_KEY, False, type=bool) # Load new setting
self.cookie_text_setting = "" # Always default to empty on launch self.cookie_text_setting = "" # Always default to empty on launch
print(f" Known.txt will be loaded/saved at: {self.config_file}") print(f" Known.txt will be loaded/saved at: {self.config_file}")
self.setWindowTitle("Kemono Downloader v4.0.0") self.setWindowTitle("Kemono Downloader v4.1.1")
# self.load_known_names_from_util() # This call is premature and causes the error. # self.load_known_names_from_util() # This call is premature and causes the error.
self.setStyleSheet(self.get_dark_theme()) self.setStyleSheet(self.get_dark_theme())
@@ -874,6 +979,7 @@ class DownloaderApp(QWidget):
self.log_signal.emit(f" Multi-part download defaults to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'} on launch") self.log_signal.emit(f" Multi-part download defaults to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'} on launch")
self.log_signal.emit(f" Cookie text defaults to: Empty on launch") self.log_signal.emit(f" Cookie text defaults to: Empty on launch")
self.log_signal.emit(f" 'Use Cookie' setting defaults to: Disabled on launch") self.log_signal.emit(f" 'Use Cookie' setting defaults to: Disabled on launch")
self.log_signal.emit(f" Scan post content for images defaults to: {'Enabled' if self.scan_content_images_setting else 'Disabled'}")
def _get_tooltip_for_character_input(self): def _get_tooltip_for_character_input(self):
return ( return (
@@ -898,6 +1004,8 @@ class DownloaderApp(QWidget):
self.cookie_browse_button.clicked.connect(self._browse_cookie_file) self.cookie_browse_button.clicked.connect(self._browse_cookie_file)
if hasattr(self, 'cookie_text_input'): # Connect text changed for manual clear detection if hasattr(self, 'cookie_text_input'): # Connect text changed for manual clear detection
self.cookie_text_input.textChanged.connect(self._handle_cookie_text_manual_change) self.cookie_text_input.textChanged.connect(self._handle_cookie_text_manual_change)
if hasattr(self, 'download_thumbnails_checkbox'): # Connect the new handler
self.download_thumbnails_checkbox.toggled.connect(self._handle_thumbnail_mode_change)
self.gui_update_timer.timeout.connect(self._process_worker_queue) self.gui_update_timer.timeout.connect(self._process_worker_queue)
self.gui_update_timer.start(100) # Check queue every 100ms self.gui_update_timer.start(100) # Check queue every 100ms
self.log_signal.connect(self.handle_main_log) self.log_signal.connect(self.handle_main_log)
@@ -940,6 +1048,9 @@ class DownloaderApp(QWidget):
if hasattr(self, 'open_known_txt_button'): # Connect the new button if hasattr(self, 'open_known_txt_button'): # Connect the new button
self.open_known_txt_button.clicked.connect(self._open_known_txt_file) self.open_known_txt_button.clicked.connect(self._open_known_txt_file)
if hasattr(self, 'add_to_filter_button'): # Connect the new "Add to Filter" button
self.add_to_filter_button.clicked.connect(self._show_add_to_filter_dialog)
def _on_character_input_changed_live(self, text): def _on_character_input_changed_live(self, text):
""" """
Called when the character input field text changes. Called when the character input field text changes.
@@ -1119,6 +1230,7 @@ class DownloaderApp(QWidget):
self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope) self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope)
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting) self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
self.settings.setValue(COOKIE_TEXT_KEY, self.cookie_text_input.text() if hasattr(self, 'cookie_text_input') else "") self.settings.setValue(COOKIE_TEXT_KEY, self.cookie_text_input.text() if hasattr(self, 'cookie_text_input') else "")
self.settings.setValue(SCAN_CONTENT_IMAGES_KEY, self.scan_content_images_checkbox.isChecked() if hasattr(self, 'scan_content_images_checkbox') else False)
self.settings.setValue(USE_COOKIE_KEY, self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False) self.settings.setValue(USE_COOKIE_KEY, self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False)
self.settings.sync() self.settings.sync()
@@ -1331,6 +1443,7 @@ class DownloaderApp(QWidget):
self.radio_videos = QRadioButton("Videos") self.radio_videos = QRadioButton("Videos")
self.radio_videos.setToolTip("Download only common video formats (MP4, MKV, WEBM, MOV, etc.).") self.radio_videos.setToolTip("Download only common video formats (MP4, MKV, WEBM, MOV, etc.).")
self.radio_only_archives = QRadioButton("📦 Only Archives") self.radio_only_archives = QRadioButton("📦 Only Archives")
self.radio_only_audio = QRadioButton("🎧 Only Audio") # New Radio Button
self.radio_only_archives.setToolTip("Exclusively download .zip and .rar files. Other file-specific options are disabled.") self.radio_only_archives.setToolTip("Exclusively download .zip and .rar files. Other file-specific options are disabled.")
self.radio_only_links = QRadioButton("🔗 Only Links") self.radio_only_links = QRadioButton("🔗 Only Links")
self.radio_only_links.setToolTip("Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled.") self.radio_only_links.setToolTip("Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled.")
@@ -1339,11 +1452,13 @@ class DownloaderApp(QWidget):
self.radio_group.addButton(self.radio_images) self.radio_group.addButton(self.radio_images)
self.radio_group.addButton(self.radio_videos) self.radio_group.addButton(self.radio_videos)
self.radio_group.addButton(self.radio_only_archives) self.radio_group.addButton(self.radio_only_archives)
self.radio_group.addButton(self.radio_only_audio) # Add to group
self.radio_group.addButton(self.radio_only_links) self.radio_group.addButton(self.radio_only_links)
radio_button_layout.addWidget(self.radio_all) radio_button_layout.addWidget(self.radio_all)
radio_button_layout.addWidget(self.radio_images) radio_button_layout.addWidget(self.radio_images)
radio_button_layout.addWidget(self.radio_videos) radio_button_layout.addWidget(self.radio_videos)
radio_button_layout.addWidget(self.radio_only_archives) radio_button_layout.addWidget(self.radio_only_archives)
radio_button_layout.addWidget(self.radio_only_audio) # Add to layout
radio_button_layout.addWidget(self.radio_only_links) radio_button_layout.addWidget(self.radio_only_links)
radio_button_layout.addStretch(1) radio_button_layout.addStretch(1)
file_filter_layout.addLayout(radio_button_layout) file_filter_layout.addLayout(radio_button_layout)
@@ -1364,12 +1479,23 @@ class DownloaderApp(QWidget):
row1_layout.addWidget(self.skip_rar_checkbox) row1_layout.addWidget(self.skip_rar_checkbox)
self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only") self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
self.download_thumbnails_checkbox.setChecked(False) self.download_thumbnails_checkbox.setChecked(False)
self.download_thumbnails_checkbox.setToolTip("Thumbnail download functionality is currently limited without the API.") self.download_thumbnails_checkbox.setToolTip(
"Downloads small preview images from the API instead of full-sized files (if available).\n"
"If 'Scan Post Content for Image URLs' is also checked, this mode will *only* download images found by the content scan (ignoring API thumbnails)."
)
row1_layout.addWidget(self.download_thumbnails_checkbox) row1_layout.addWidget(self.download_thumbnails_checkbox)
self.scan_content_images_checkbox = QCheckBox("Scan Content for Images") # Shortened Label
self.scan_content_images_checkbox.setToolTip(
self._original_scan_content_tooltip) # Use stored original tooltip
self.scan_content_images_checkbox.setChecked(self.scan_content_images_setting) # Set from loaded setting
row1_layout.addWidget(self.scan_content_images_checkbox) # Added to row1_layout
self.compress_images_checkbox = QCheckBox("Compress Large Images (to WebP)") self.compress_images_checkbox = QCheckBox("Compress Large Images (to WebP)")
self.compress_images_checkbox.setChecked(False) self.compress_images_checkbox.setChecked(False)
self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).") self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).")
row1_layout.addWidget(self.compress_images_checkbox) row1_layout.addWidget(self.compress_images_checkbox)
row1_layout.addStretch(1) row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(row1_layout) checkboxes_group_layout.addLayout(row1_layout)
@@ -1457,6 +1583,7 @@ class DownloaderApp(QWidget):
self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode") self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
self.manga_mode_checkbox.setToolTip("Downloads posts from oldest to newest and renames files based on post title (for creator feeds only).") self.manga_mode_checkbox.setToolTip("Downloads posts from oldest to newest and renames files based on post title (for creator feeds only).")
self.manga_mode_checkbox.setChecked(False) self.manga_mode_checkbox.setChecked(False)
advanced_row2_layout.addWidget(self.manga_mode_checkbox) # Keep manga mode checkbox here advanced_row2_layout.addWidget(self.manga_mode_checkbox) # Keep manga mode checkbox here
advanced_row2_layout.addStretch(1) advanced_row2_layout.addStretch(1)
@@ -1516,12 +1643,17 @@ class DownloaderApp(QWidget):
self.new_char_input.setPlaceholderText("Add new show/character name") self.new_char_input.setPlaceholderText("Add new show/character name")
self.add_char_button = QPushButton(" Add") self.add_char_button = QPushButton(" Add")
self.add_char_button.setToolTip("Add the name from the input field to the 'Known Shows/Characters' list.") self.add_char_button.setToolTip("Add the name from the input field to the 'Known Shows/Characters' list.")
self.add_to_filter_button = QPushButton("⤵️ Add to Filter") # New Button
self.add_to_filter_button.setToolTip("Select names from 'Known Shows/Characters' list to add to the 'Filter by Character(s)' field above.")
self.delete_char_button = QPushButton("🗑️ Delete Selected") self.delete_char_button = QPushButton("🗑️ Delete Selected")
self.delete_char_button.setToolTip("Delete the selected name(s) from the 'Known Shows/Characters' list.") self.delete_char_button.setToolTip("Delete the selected name(s) from the 'Known Shows/Characters' list.")
# Connect add_char_button to a new handler that calls the refactored add_new_character # Connect add_char_button to a new handler that calls the refactored add_new_character
self.add_char_button.clicked.connect(self._handle_ui_add_new_character) self.add_char_button.clicked.connect(self._handle_ui_add_new_character)
self.new_char_input.returnPressed.connect(self.add_char_button.click) self.new_char_input.returnPressed.connect(self.add_char_button.click)
self.delete_char_button.clicked.connect(self.delete_selected_character) self.delete_char_button.clicked.connect(self.delete_selected_character)
char_manage_layout.addWidget(self.new_char_input, 2) char_manage_layout.addWidget(self.new_char_input, 2)
char_manage_layout.addWidget(self.add_char_button, 0) char_manage_layout.addWidget(self.add_char_button, 0)
@@ -1533,6 +1665,7 @@ class DownloaderApp(QWidget):
self.known_names_help_button.clicked.connect(self._show_feature_guide) self.known_names_help_button.clicked.connect(self._show_feature_guide)
char_manage_layout.addWidget(self.add_to_filter_button, 0) # Add new button to layout
char_manage_layout.addWidget(self.delete_char_button, 0) char_manage_layout.addWidget(self.delete_char_button, 0)
char_manage_layout.addWidget(self.known_names_help_button, 0) # Moved to the end (rightmost) char_manage_layout.addWidget(self.known_names_help_button, 0) # Moved to the end (rightmost)
left_layout.addLayout(char_manage_layout) left_layout.addLayout(char_manage_layout)
@@ -1563,6 +1696,14 @@ class DownloaderApp(QWidget):
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
log_title_layout.addWidget(self.manga_rename_toggle_button) log_title_layout.addWidget(self.manga_rename_toggle_button)
# NEW: Manga Date Prefix Input
self.manga_date_prefix_input = QLineEdit()
self.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames") # Generalized
self.manga_date_prefix_input.setToolTip("Optional prefix for 'Date Based' or 'Original File' manga filenames (e.g., 'Series Name').\nIf empty, files will be named based on the style without a prefix.") # Generalized
self.manga_date_prefix_input.setVisible(False) # Initially hidden
self.manga_date_prefix_input.setFixedWidth(160) # Adjust as needed
log_title_layout.addWidget(self.manga_date_prefix_input) # Add to layout
self.multipart_toggle_button = QPushButton() self.multipart_toggle_button = QPushButton()
self.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.") self.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.")
self.multipart_toggle_button.setFixedWidth(130) # Adjust width as needed self.multipart_toggle_button.setFixedWidth(130) # Adjust width as needed
@@ -1669,6 +1810,8 @@ class DownloaderApp(QWidget):
self._update_skip_scope_button_text() self._update_skip_scope_button_text()
self._update_char_filter_scope_button_text() self._update_char_filter_scope_button_text()
self._update_multithreading_for_date_mode() # Ensure correct initial state self._update_multithreading_for_date_mode() # Ensure correct initial state
if hasattr(self, 'download_thumbnails_checkbox'): # Set initial state for scan_content checkbox based on thumbnail checkbox
self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked())
def _browse_cookie_file(self): def _browse_cookie_file(self):
"""Opens a file dialog to select a cookie file.""" """Opens a file dialog to select a cookie file."""
@@ -1990,11 +2133,13 @@ class DownloaderApp(QWidget):
filter_mode_text = button.text() filter_mode_text = button.text()
is_only_links = (filter_mode_text == "🔗 Only Links") is_only_links = (filter_mode_text == "🔗 Only Links")
is_only_audio = (filter_mode_text == "🎧 Only Audio")
is_only_archives = (filter_mode_text == "📦 Only Archives") is_only_archives = (filter_mode_text == "📦 Only Archives")
if self.skip_scope_toggle_button: if self.skip_scope_toggle_button:
self.skip_scope_toggle_button.setVisible(not (is_only_links or is_only_archives)) self.skip_scope_toggle_button.setVisible(not (is_only_links or is_only_archives or is_only_audio))
if hasattr(self, 'multipart_toggle_button') and self.multipart_toggle_button: if hasattr(self, 'multipart_toggle_button') and self.multipart_toggle_button:
self.multipart_toggle_button.setVisible(not (is_only_links or is_only_archives)) self.multipart_toggle_button.setVisible(not (is_only_links or is_only_archives or is_only_audio))
if self.link_search_input: self.link_search_input.setVisible(is_only_links) if self.link_search_input: self.link_search_input.setVisible(is_only_links)
if self.link_search_button: self.link_search_button.setVisible(is_only_links) if self.link_search_button: self.link_search_button.setVisible(is_only_links)
@@ -2009,7 +2154,7 @@ class DownloaderApp(QWidget):
self.download_btn.setText("⬇️ Start Download") self.download_btn.setText("⬇️ Start Download")
if not is_only_links and self.link_search_input: self.link_search_input.clear() if not is_only_links and self.link_search_input: self.link_search_input.clear()
file_download_mode_active = not is_only_links file_download_mode_active = not is_only_links # Audio mode is a file download mode
if self.dir_input: self.dir_input.setEnabled(file_download_mode_active) if self.dir_input: self.dir_input.setEnabled(file_download_mode_active)
if self.dir_button: self.dir_button.setEnabled(file_download_mode_active) if self.dir_button: self.dir_button.setEnabled(file_download_mode_active)
@@ -2019,23 +2164,23 @@ class DownloaderApp(QWidget):
if hasattr(self, 'remove_from_filename_input'): self.remove_from_filename_input.setEnabled(file_download_mode_active) if hasattr(self, 'remove_from_filename_input'): self.remove_from_filename_input.setEnabled(file_download_mode_active)
if self.skip_zip_checkbox: if self.skip_zip_checkbox:
can_skip_zip = not is_only_links and not is_only_archives can_skip_zip = file_download_mode_active and not is_only_archives # Audio mode allows skipping zip
self.skip_zip_checkbox.setEnabled(can_skip_zip) self.skip_zip_checkbox.setEnabled(can_skip_zip)
if is_only_archives: if is_only_archives:
self.skip_zip_checkbox.setChecked(False) self.skip_zip_checkbox.setChecked(False)
if self.skip_rar_checkbox: if self.skip_rar_checkbox:
can_skip_rar = not is_only_links and not is_only_archives can_skip_rar = file_download_mode_active and not is_only_archives # Audio mode allows skipping rar
self.skip_rar_checkbox.setEnabled(can_skip_rar) self.skip_rar_checkbox.setEnabled(can_skip_rar)
if is_only_archives: if is_only_archives:
self.skip_rar_checkbox.setChecked(False) self.skip_rar_checkbox.setChecked(False)
other_file_proc_enabled = not is_only_links and not is_only_archives other_file_proc_enabled = file_download_mode_active and not is_only_archives # Thumbnails/compression relevant if not archives
if self.download_thumbnails_checkbox: self.download_thumbnails_checkbox.setEnabled(other_file_proc_enabled) if self.download_thumbnails_checkbox: self.download_thumbnails_checkbox.setEnabled(other_file_proc_enabled)
if self.compress_images_checkbox: self.compress_images_checkbox.setEnabled(other_file_proc_enabled) if self.compress_images_checkbox: self.compress_images_checkbox.setEnabled(other_file_proc_enabled)
if self.external_links_checkbox: if self.external_links_checkbox:
can_show_external_log_option = not is_only_links and not is_only_archives can_show_external_log_option = file_download_mode_active and not is_only_archives # External links relevant if not archives
self.external_links_checkbox.setEnabled(can_show_external_log_option) self.external_links_checkbox.setEnabled(can_show_external_log_option)
if not can_show_external_log_option: if not can_show_external_log_option:
self.external_links_checkbox.setChecked(False) self.external_links_checkbox.setChecked(False)
@@ -2056,6 +2201,12 @@ class DownloaderApp(QWidget):
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0]) if self.log_splitter: self.log_splitter.setSizes([self.height(), 0])
if self.main_log_output: self.main_log_output.clear() if self.main_log_output: self.main_log_output.clear()
self.log_signal.emit("="*20 + " Mode changed to: Only Archives " + "="*20) self.log_signal.emit("="*20 + " Mode changed to: Only Archives " + "="*20)
elif is_only_audio:
self.progress_log_label.setText("📜 Progress Log (Audio Only):")
if self.external_log_output: self.external_log_output.hide() # Typically no external log for specific content types unless explicitly enabled
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0])
if self.main_log_output: self.main_log_output.clear()
self.log_signal.emit("="*20 + " Mode changed to: Only Archives " + "="*20)
else: else:
self.progress_log_label.setText("📜 Progress Log:") self.progress_log_label.setText("📜 Progress Log:")
self.update_external_links_setting(self.external_links_checkbox.isChecked() if self.external_links_checkbox else False) self.update_external_links_setting(self.external_links_checkbox.isChecked() if self.external_links_checkbox else False)
@@ -2066,7 +2217,7 @@ class DownloaderApp(QWidget):
# Determine if character filter section should be active (visible and enabled) # Determine if character filter section should be active (visible and enabled)
# It should be active if we are in a file downloading mode (not 'Only Links' or 'Only Archives') # It should be active if we are in a file downloading mode (not 'Only Links' or 'Only Archives')
character_filter_should_be_active = not is_only_links and not is_only_archives character_filter_should_be_active = file_download_mode_active and not is_only_archives
if self.character_filter_widget: if self.character_filter_widget:
self.character_filter_widget.setVisible(character_filter_should_be_active) self.character_filter_widget.setVisible(character_filter_should_be_active)
@@ -2160,6 +2311,8 @@ class DownloaderApp(QWidget):
return 'video' return 'video'
elif self.radio_only_archives and self.radio_only_archives.isChecked(): elif self.radio_only_archives and self.radio_only_archives.isChecked():
return 'archive' return 'archive'
elif hasattr(self, 'radio_only_audio') and self.radio_only_audio.isChecked(): # New audio mode
return 'audio'
elif self.radio_all.isChecked(): elif self.radio_all.isChecked():
return 'all' return 'all'
return 'all' return 'all'
@@ -2463,7 +2616,8 @@ class DownloaderApp(QWidget):
not_only_links_or_archives_mode = not ( not_only_links_or_archives_mode = not (
(self.radio_only_links and self.radio_only_links.isChecked()) or (self.radio_only_links and self.radio_only_links.isChecked()) or
(self.radio_only_archives and self.radio_only_archives.isChecked()) (self.radio_only_archives and self.radio_only_archives.isChecked()) or
(hasattr(self, 'radio_only_audio') and self.radio_only_audio.isChecked()) # Audio mode also hides custom folder
) )
should_show_custom_folder = is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode should_show_custom_folder = is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode
@@ -2478,7 +2632,10 @@ class DownloaderApp(QWidget):
def update_ui_for_subfolders(self, separate_folders_by_name_title_checked: bool): def update_ui_for_subfolders(self, separate_folders_by_name_title_checked: bool):
is_only_links = self.radio_only_links and self.radio_only_links.isChecked() is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked() is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked()
is_only_audio = hasattr(self, 'radio_only_audio') and self.radio_only_audio.isChecked()
# "Subfolder per Post" can be enabled if it's not "Only Links" and not "Only Archives".
# This means it CAN be enabled for "All", "Images/GIFs", "Videos", and "Only Audio" modes.
can_enable_subfolder_per_post_checkbox = not is_only_links and not is_only_archives can_enable_subfolder_per_post_checkbox = not is_only_links and not is_only_archives
if self.use_subfolder_per_post_checkbox: if self.use_subfolder_per_post_checkbox:
@@ -2552,10 +2709,10 @@ class DownloaderApp(QWidget):
elif self.manga_filename_style == STYLE_ORIGINAL_NAME: elif self.manga_filename_style == STYLE_ORIGINAL_NAME:
self.manga_rename_toggle_button.setText("Name: Original File") self.manga_rename_toggle_button.setText("Name: Original File")
self.manga_rename_toggle_button.setToolTip( self.manga_rename_toggle_button.setToolTip(
"Manga Filename Style: Original File Name\n\n" "Manga Filename Style: Original File Name\n\n" # Updated tooltip
"When Manga/Comic Mode is active for a creator feed:\n" "When Manga/Comic Mode is active for a creator feed:\n"
"- *All* files in a post will attempt to keep their original filenames as provided by the site (e.g., \"001.jpg\", \"page_02.png\").\n" "- *All* files in a post will attempt to keep their original filenames as provided by the site (e.g., \"001.jpg\", \"page_02.png\").\n"
"- This can be useful if original names are already well-structured and sequential.\n" "- An optional prefix can be entered in the field next to this button (e.g., 'MySeries_001.jpg').\n"
"- If original names are inconsistent, using \"Post Title\" style is often better.\n" "- If original names are inconsistent, using \"Post Title\" style is often better.\n"
"- Example: Post \"Chapter 1: The Beginning\" with files \"001.jpg\", \"002.jpg\".\n" "- Example: Post \"Chapter 1: The Beginning\" with files \"001.jpg\", \"002.jpg\".\n"
" Downloads as: \"001.jpg\", \"002.jpg\".\n\n" " Downloads as: \"001.jpg\", \"002.jpg\".\n\n"
@@ -2578,7 +2735,8 @@ class DownloaderApp(QWidget):
self.manga_rename_toggle_button.setToolTip( self.manga_rename_toggle_button.setToolTip(
"Manga Filename Style: Date Based\n\n" "Manga Filename Style: Date Based\n\n"
"When Manga/Comic Mode is active for a creator feed:\n" "When Manga/Comic Mode is active for a creator feed:\n"
"- Files will be named sequentially (001.ext, 002.ext, ...) based on post publication order (oldest to newest).\n" "- Files will be named sequentially (001.ext, 002.ext, ...) based on post publication order.\n"
"- An optional prefix can be entered in the field next to this button (e.g., 'MySeries_001.jpg').\n"
"- To ensure correct numbering, multithreading for post processing is automatically disabled when this style is active.\n\n" "- To ensure correct numbering, multithreading for post processing is automatically disabled when this style is active.\n\n"
"Click to change to: Post Title" "Click to change to: Post Title"
) )
@@ -2612,13 +2770,14 @@ class DownloaderApp(QWidget):
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style) self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.sync() self.settings.sync()
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
self._update_multithreading_for_date_mode() # Update multithreading state based on new style self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False) # Update UI based on new style
self.log_signal.emit(f" Manga filename style changed to: '{self.manga_filename_style}'") self.log_signal.emit(f" Manga filename style changed to: '{self.manga_filename_style}'")
def update_ui_for_manga_mode(self, checked): def update_ui_for_manga_mode(self, checked):
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked() is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives_mode = self.radio_only_archives and self.radio_only_archives.isChecked() is_only_archives_mode = self.radio_only_archives and self.radio_only_archives.isChecked()
is_only_audio_mode = hasattr(self, 'radio_only_audio') and self.radio_only_audio.isChecked()
url_text = self.link_input.text().strip() if self.link_input else "" url_text = self.link_input.text().strip() if self.link_input else ""
_, _, post_id = extract_post_info(url_text) _, _, post_id = extract_post_info(url_text)
@@ -2633,14 +2792,17 @@ class DownloaderApp(QWidget):
manga_mode_effectively_on = is_creator_feed and checked manga_mode_effectively_on = is_creator_feed and checked
if self.manga_rename_toggle_button: if self.manga_rename_toggle_button:
self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode)) self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode))
# Always update page range enabled state, as it depends on URL type, not directly manga mode. # Always update page range enabled state, as it depends on URL type, not directly manga mode.
self.update_page_range_enabled_state() self.update_page_range_enabled_state()
file_download_mode_active = not (self.radio_only_links and self.radio_only_links.isChecked()) current_filename_style = self.manga_filename_style
# Character filter widgets should be enabled if it's a file download mode
enable_char_filter_widgets = file_download_mode_active and not (self.radio_only_archives and self.radio_only_archives.isChecked()) # Character filter widgets should be enabled if it's a file download mode where character
# filtering makes sense (i.e., not 'Only Links' and not 'Only Archives').
# 'Only Audio' mode is a file download mode where character filters are applicable.
enable_char_filter_widgets = not is_only_links_mode and not is_only_archives_mode
if self.character_input: if self.character_input:
self.character_input.setEnabled(enable_char_filter_widgets) self.character_input.setEnabled(enable_char_filter_widgets)
@@ -2649,6 +2811,23 @@ class DownloaderApp(QWidget):
self.char_filter_scope_toggle_button.setEnabled(enable_char_filter_widgets) self.char_filter_scope_toggle_button.setEnabled(enable_char_filter_widgets)
if self.character_filter_widget: # Also ensure the main widget visibility is correct if self.character_filter_widget: # Also ensure the main widget visibility is correct
self.character_filter_widget.setVisible(enable_char_filter_widgets) self.character_filter_widget.setVisible(enable_char_filter_widgets)
# Visibility for manga date prefix input
show_date_prefix_input = (
manga_mode_effectively_on and
(current_filename_style == STYLE_DATE_BASED or current_filename_style == STYLE_ORIGINAL_NAME) and # MODIFIED
not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode)
)
if hasattr(self, 'manga_date_prefix_input'):
self.manga_date_prefix_input.setVisible(show_date_prefix_input)
if not show_date_prefix_input: # Clear if not visible
self.manga_date_prefix_input.clear()
# Visibility for multipart toggle button
if hasattr(self, 'multipart_toggle_button'):
show_multipart_button = not (show_date_prefix_input or is_only_links_mode or is_only_archives_mode or is_only_audio_mode)
self.multipart_toggle_button.setVisible(show_multipart_button)
self._update_multithreading_for_date_mode() # Update multithreading state based on manga mode self._update_multithreading_for_date_mode() # Update multithreading state based on manga mode
@@ -2787,6 +2966,7 @@ class DownloaderApp(QWidget):
raw_remove_filename_words = self.remove_from_filename_input.text().strip() if hasattr(self, 'remove_from_filename_input') else "" raw_remove_filename_words = self.remove_from_filename_input.text().strip() if hasattr(self, 'remove_from_filename_input') else ""
allow_multipart = self.allow_multipart_download_setting # Use the internal setting allow_multipart = self.allow_multipart_download_setting # Use the internal setting
remove_from_filename_words_list = [word.strip() for word in raw_remove_filename_words.split(',') if word.strip()] remove_from_filename_words_list = [word.strip() for word in raw_remove_filename_words.split(',') if word.strip()]
scan_content_for_images = self.scan_content_images_checkbox.isChecked() if hasattr(self, 'scan_content_images_checkbox') else False
use_cookie_from_checkbox = self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False use_cookie_from_checkbox = self.use_cookie_checkbox.isChecked() if hasattr(self, 'use_cookie_checkbox') else False
app_base_dir_for_cookies = os.path.dirname(self.config_file) # Directory of Known.txt app_base_dir_for_cookies = os.path.dirname(self.config_file) # Directory of Known.txt
cookie_text_from_input = self.cookie_text_input.text().strip() if hasattr(self, 'cookie_text_input') and use_cookie_from_checkbox else "" cookie_text_from_input = self.cookie_text_input.text().strip() if hasattr(self, 'cookie_text_input') and use_cookie_from_checkbox else ""
@@ -2797,7 +2977,8 @@ class DownloaderApp(QWidget):
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked()) extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
backend_filter_mode = self.get_filter_mode() backend_filter_mode = self.get_filter_mode()
user_selected_filter_text = self.radio_group.checkedButton().text() if self.radio_group.checkedButton() else "All" checked_radio_button = self.radio_group.checkedButton()
user_selected_filter_text = checked_radio_button.text() if checked_radio_button else "All"
if selected_cookie_file_path_for_backend: if selected_cookie_file_path_for_backend:
cookie_text_from_input = "" cookie_text_from_input = ""
@@ -2807,6 +2988,9 @@ class DownloaderApp(QWidget):
else: else:
effective_skip_zip = self.skip_zip_checkbox.isChecked() effective_skip_zip = self.skip_zip_checkbox.isChecked()
effective_skip_rar = self.skip_rar_checkbox.isChecked() effective_skip_rar = self.skip_rar_checkbox.isChecked()
if backend_filter_mode == 'audio': # If audio mode, don't skip archives by default unless user explicitly checks
effective_skip_zip = self.skip_zip_checkbox.isChecked() # Keep user's choice
effective_skip_rar = self.skip_rar_checkbox.isChecked() # Keep user's choice
if not api_url: QMessageBox.critical(self, "Input Error", "URL is required."); return if not api_url: QMessageBox.critical(self, "Input Error", "URL is required."); return
if not extract_links_only and not output_dir: if not extract_links_only and not output_dir:
@@ -2819,7 +3003,7 @@ class DownloaderApp(QWidget):
if not extract_links_only and not os.path.isdir(output_dir): if not extract_links_only and not os.path.isdir(output_dir):
reply = QMessageBox.question(self, "Create Directory?", reply = QMessageBox.question(self, "Create Directory?",
f"The directory '{output_dir}' does not exist.\nCreate it now?", f"The directory '{output_dir}' does not exist.\nCreate it now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) # type: ignore
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
try: os.makedirs(output_dir, exist_ok=True); self.log_signal.emit(f" Created directory: {output_dir}") try: os.makedirs(output_dir, exist_ok=True); self.log_signal.emit(f" Created directory: {output_dir}")
except Exception as e: QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}"); return except Exception as e: QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}"); return
@@ -2829,8 +3013,22 @@ class DownloaderApp(QWidget):
QMessageBox.warning(self, "Missing Dependency", "Pillow library (for image compression) not found. Compression will be disabled.") QMessageBox.warning(self, "Missing Dependency", "Pillow library (for image compression) not found. Compression will be disabled.")
compress_images = False; self.compress_images_checkbox.setChecked(False) compress_images = False; self.compress_images_checkbox.setChecked(False)
# Initialize log_messages here, before it's potentially used by manga_date_prefix_text logging
log_messages = ["="*40, f"🚀 Starting {'Link Extraction' if extract_links_only else ('Archive Download' if backend_filter_mode == 'archive' else 'Download')} @ {time.strftime('%Y-%m-%d %H:%M:%S')}", f" URL: {api_url}"]
current_mode_log_text = "Download"
if extract_links_only: current_mode_log_text = "Link Extraction"
elif backend_filter_mode == 'archive': current_mode_log_text = "Archive Download"
elif backend_filter_mode == 'audio': current_mode_log_text = "Audio Download"
manga_mode = manga_mode_is_checked and not post_id_from_url manga_mode = manga_mode_is_checked and not post_id_from_url
manga_date_prefix_text = ""
if manga_mode and \
(self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_ORIGINAL_NAME) and \
hasattr(self, 'manga_date_prefix_input'):
manga_date_prefix_text = self.manga_date_prefix_input.text().strip()
if manga_date_prefix_text: # Log only if prefix is provided (log_messages is now initialized)
log_messages.append(f" ↳ Manga Date Prefix: '{manga_date_prefix_text}'")
start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip() start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip()
start_page, end_page = None, None start_page, end_page = None, None
@@ -3031,7 +3229,7 @@ class DownloaderApp(QWidget):
effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) # For posts effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) # For posts
effective_num_file_threads_per_worker = 1 # Files within each post worker are sequential effective_num_file_threads_per_worker = 1 # Files within each post worker are sequential
log_messages = ["="*40, f"🚀 Starting {'Link Extraction' if extract_links_only else ('Archive Download' if backend_filter_mode == 'archive' else 'Download')} @ {time.strftime('%Y-%m-%d %H:%M:%S')}", f" URL: {api_url}"] # log_messages initialization was moved earlier
if not extract_links_only: log_messages.append(f" Save Location: {output_dir}") if not extract_links_only: log_messages.append(f" Save Location: {output_dir}")
if post_id_from_url: if post_id_from_url:
@@ -3072,6 +3270,7 @@ class DownloaderApp(QWidget):
f" Compress Images: {'Enabled' if compress_images else 'Disabled'}", f" Compress Images: {'Enabled' if compress_images else 'Disabled'}",
f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}" # Removed duplicate file handling log f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}" # Removed duplicate file handling log
]) ])
log_messages.append(f" Scan Post Content for Images: {'Enabled' if scan_content_for_images else 'Disabled'}")
else: else:
log_messages.append(f" Mode: Extracting Links Only") log_messages.append(f" Mode: Extracting Links Only")
@@ -3138,8 +3337,10 @@ class DownloaderApp(QWidget):
'manga_mode_active': manga_mode, 'manga_mode_active': manga_mode,
'unwanted_keywords': unwanted_keywords_for_folders, 'unwanted_keywords': unwanted_keywords_for_folders,
'cancellation_event': self.cancellation_event, 'cancellation_event': self.cancellation_event,
'manga_date_prefix': manga_date_prefix_text, # NEW ARGUMENT
'dynamic_character_filter_holder': self.dynamic_character_filter_holder, # Pass the holder 'dynamic_character_filter_holder': self.dynamic_character_filter_holder, # Pass the holder
'pause_event': self.pause_event, # Explicitly add pause_event here 'pause_event': self.pause_event, # Explicitly add pause_event here
'scan_content_for_images': scan_content_for_images, # Pass new flag
'manga_filename_style': self.manga_filename_style, 'manga_filename_style': self.manga_filename_style,
'num_file_threads_for_worker': effective_num_file_threads_per_worker, 'num_file_threads_for_worker': effective_num_file_threads_per_worker,
'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread, 'manga_date_file_counter_ref': manga_date_file_counter_ref_for_thread,
@@ -3153,7 +3354,7 @@ class DownloaderApp(QWidget):
try: try:
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
self.log_signal.emit(f" Initializing multi-threaded {'link extraction' if extract_links_only else 'download'} with {effective_num_post_workers} post workers...") self.log_signal.emit(f" Initializing multi-threaded {current_mode_log_text.lower()} with {effective_num_post_workers} post workers...")
args_template['emitter'] = self.worker_to_gui_queue # For multi-threaded, use the queue args_template['emitter'] = self.worker_to_gui_queue # For multi-threaded, use the queue
self.start_multi_threaded_download(num_post_workers=effective_num_post_workers, **args_template) self.start_multi_threaded_download(num_post_workers=effective_num_post_workers, **args_template)
else: else:
@@ -3169,8 +3370,8 @@ class DownloaderApp(QWidget):
'show_external_links', 'extract_links_only', 'num_file_threads_for_worker', 'show_external_links', 'extract_links_only', 'num_file_threads_for_worker',
'start_page', 'end_page', 'target_post_id_from_initial_url', 'start_page', 'end_page', 'target_post_id_from_initial_url',
'manga_date_file_counter_ref', 'manga_date_file_counter_ref',
'manga_global_file_counter_ref', # Pass new counter for single thread mode 'manga_global_file_counter_ref', 'manga_date_prefix', # Pass new counter and prefix for single thread mode
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'scan_content_for_images', # Added scan_content_for_images
'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file
] ]
args_template['skip_current_file_flag'] = None args_template['skip_current_file_flag'] = None
@@ -3365,15 +3566,15 @@ class DownloaderApp(QWidget):
'downloaded_files_lock', 'downloaded_file_hashes_lock', 'remove_from_filename_words_list', 'dynamic_character_filter_holder', # Added holder 'downloaded_files_lock', 'downloaded_file_hashes_lock', 'remove_from_filename_words_list', 'dynamic_character_filter_holder', # Added holder
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'skip_words_list', 'skip_words_scope', 'char_filter_scope',
'show_external_links', 'extract_links_only', 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', # Added selected_cookie_file 'show_external_links', 'extract_links_only', 'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', # Added selected_cookie_file
'num_file_threads', 'skip_current_file_flag', 'manga_date_file_counter_ref', 'num_file_threads', 'skip_current_file_flag', 'manga_date_file_counter_ref', 'scan_content_for_images', # Added scan_content_for_images
'manga_mode_active', 'manga_filename_style', 'manga_mode_active', 'manga_filename_style', 'manga_date_prefix', # ADD manga_date_prefix
'manga_global_file_counter_ref' # Add new counter here 'manga_global_file_counter_ref' # Add new counter here
] ]
ppw_optional_keys_with_defaults = { ppw_optional_keys_with_defaults = {
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list', 'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list',
'show_external_links', 'extract_links_only', 'duplicate_file_mode', # Added duplicate_file_mode here 'show_external_links', 'extract_links_only', 'duplicate_file_mode', # Added duplicate_file_mode here
'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style', 'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style', 'manga_date_prefix', # ADD manga_date_prefix
'manga_date_file_counter_ref', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file' # Added selected_cookie_file 'manga_date_file_counter_ref', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file'
} }
# Batching is generally for high worker counts. # Batching is generally for high worker counts.
# If num_post_workers is low (e.g., 1), the num_post_workers > POST_WORKER_BATCH_THRESHOLD condition will prevent batching. # If num_post_workers is low (e.g., 1), the num_post_workers > POST_WORKER_BATCH_THRESHOLD condition will prevent batching.
@@ -3503,7 +3704,8 @@ class DownloaderApp(QWidget):
self.manga_rename_toggle_button, # Visibility handled by update_ui_for_manga_mode self.manga_rename_toggle_button, # Visibility handled by update_ui_for_manga_mode
self.cookie_browse_button, # Add cookie browse button self.cookie_browse_button, # Add cookie browse button
self.multipart_toggle_button, self.multipart_toggle_button,
self.cookie_text_input, # Add cookie text input self.cookie_text_input, # Add cookie text input,
self.scan_content_images_checkbox, # Add scan content checkbox
self.use_cookie_checkbox, # Add cookie checkbox here self.use_cookie_checkbox, # Add cookie checkbox here
self.external_links_checkbox self.external_links_checkbox
] ]
@@ -3512,16 +3714,17 @@ class DownloaderApp(QWidget):
all_potentially_toggleable_widgets = [ all_potentially_toggleable_widgets = [
self.link_input, self.dir_input, self.dir_button, self.link_input, self.dir_input, self.dir_button,
self.page_range_label, self.start_page_input, self.to_label, self.end_page_input, self.page_range_label, self.start_page_input, self.to_label, self.end_page_input,
self.character_input, self.char_filter_scope_toggle_button, self.character_input, self.char_filter_scope_toggle_button, self.character_filter_widget, # Added character_filter_widget
self.filters_and_custom_folder_container_widget, # Added container
self.custom_folder_label, self.custom_folder_input, self.custom_folder_label, self.custom_folder_input,
self.skip_words_input, self.skip_scope_toggle_button, self.remove_from_filename_input, self.skip_words_input, self.skip_scope_toggle_button, self.remove_from_filename_input,
self.radio_all, self.radio_images, self.radio_videos, self.radio_only_archives, self.radio_only_links, self.radio_all, self.radio_images, self.radio_videos, self.radio_only_archives, self.radio_only_links,
self.skip_zip_checkbox, self.skip_rar_checkbox, self.download_thumbnails_checkbox, self.compress_images_checkbox, self.skip_zip_checkbox, self.skip_rar_checkbox, self.download_thumbnails_checkbox, self.compress_images_checkbox,
self.use_subfolders_checkbox, self.use_subfolder_per_post_checkbox, self.use_subfolders_checkbox, self.use_subfolder_per_post_checkbox, self.scan_content_images_checkbox, # Added scan_content_images_checkbox
self.use_multithreading_checkbox, self.thread_count_input, self.thread_count_label, self.use_multithreading_checkbox, self.thread_count_input, self.thread_count_label,
self.external_links_checkbox, self.manga_mode_checkbox, self.manga_rename_toggle_button, self.use_cookie_checkbox, self.cookie_text_input, self.cookie_browse_button, self.external_links_checkbox, self.manga_mode_checkbox, self.manga_rename_toggle_button, self.use_cookie_checkbox, self.cookie_text_input, self.cookie_browse_button,
self.multipart_toggle_button, self.multipart_toggle_button, self.radio_only_audio, # Added radio_only_audio
self.character_search_input, self.new_char_input, self.add_char_button, self.delete_char_button, self.character_search_input, self.new_char_input, self.add_char_button, self.add_to_filter_button, self.delete_char_button, # Added add_to_filter_button
self.reset_button self.reset_button
] ]
@@ -3542,9 +3745,10 @@ class DownloaderApp(QWidget):
if self.external_links_checkbox: if self.external_links_checkbox:
is_only_links = self.radio_only_links and self.radio_only_links.isChecked() is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked() is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked()
can_enable_ext_links = enabled and not is_only_links and not is_only_archives is_only_audio = hasattr(self, 'radio_only_audio') and self.radio_only_audio.isChecked()
can_enable_ext_links = enabled and not is_only_links and not is_only_archives and not is_only_audio
self.external_links_checkbox.setEnabled(can_enable_ext_links) self.external_links_checkbox.setEnabled(can_enable_ext_links)
if self.is_paused and not is_only_links and not is_only_archives: if self.is_paused and not is_only_links and not is_only_archives and not is_only_audio:
self.external_links_checkbox.setEnabled(True) self.external_links_checkbox.setEnabled(True)
if hasattr(self, 'use_cookie_checkbox'): if hasattr(self, 'use_cookie_checkbox'):
self.use_cookie_checkbox.setEnabled(enabled or self.is_paused) self.use_cookie_checkbox.setEnabled(enabled or self.is_paused)
@@ -3607,6 +3811,7 @@ class DownloaderApp(QWidget):
self.skip_zip_checkbox.setChecked(True); self.skip_rar_checkbox.setChecked(True); self.download_thumbnails_checkbox.setChecked(False); self.skip_zip_checkbox.setChecked(True); self.skip_rar_checkbox.setChecked(True); self.download_thumbnails_checkbox.setChecked(False);
self.compress_images_checkbox.setChecked(False); self.use_subfolders_checkbox.setChecked(True); self.compress_images_checkbox.setChecked(False); self.use_subfolders_checkbox.setChecked(True);
self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True); self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True);
if hasattr(self, 'scan_content_images_checkbox'): self.scan_content_images_checkbox.setChecked(False) # Reset new checkbox
self.external_links_checkbox.setChecked(False) self.external_links_checkbox.setChecked(False)
if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False) if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False)
if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setChecked(self.use_cookie_setting) # Reset to loaded or False if hasattr(self, 'use_cookie_checkbox'): self.use_cookie_checkbox.setChecked(self.use_cookie_setting) # Reset to loaded or False
@@ -3619,7 +3824,9 @@ class DownloaderApp(QWidget):
self.skip_words_scope = SKIP_SCOPE_POSTS # Default self.skip_words_scope = SKIP_SCOPE_POSTS # Default
self._update_skip_scope_button_text() self._update_skip_scope_button_text()
self.char_filter_scope = CHAR_SCOPE_TITLE # Default if hasattr(self, 'manga_date_prefix_input'): self.manga_date_prefix_input.clear() # Clear prefix input
self.char_filter_scope = CHAR_SCOPE_FILES # Default to Files on soft reset
self._update_char_filter_scope_button_text() self._update_char_filter_scope_button_text()
self.manga_filename_style = STYLE_POST_TITLE # Reset to app default self.manga_filename_style = STYLE_POST_TITLE # Reset to app default
@@ -3753,6 +3960,24 @@ class DownloaderApp(QWidget):
self.set_ui_enabled(True) # Full UI reset if not retrying self.set_ui_enabled(True) # Full UI reset if not retrying
def _handle_thumbnail_mode_change(self, thumbnails_checked):
"""Handles UI changes when 'Download Thumbnails Only' is toggled."""
if not hasattr(self, 'scan_content_images_checkbox'):
return
if thumbnails_checked:
self.scan_content_images_checkbox.setChecked(True)
self.scan_content_images_checkbox.setEnabled(False)
self.scan_content_images_checkbox.setToolTip(
"Automatically enabled and locked because 'Download Thumbnails Only' is active.\n"
"In this mode, only images found by content scanning will be downloaded."
)
else:
self.scan_content_images_checkbox.setEnabled(True)
# Revert to unchecked when thumbnail mode is off. User can manually re-check if desired.
self.scan_content_images_checkbox.setChecked(False)
self.scan_content_images_checkbox.setToolTip(self._original_scan_content_tooltip)
def _start_failed_files_retry_session(self): def _start_failed_files_retry_session(self):
self.log_signal.emit(f"🔄 Starting retry session for {len(self.retryable_failed_files_info)} file(s)...") self.log_signal.emit(f"🔄 Starting retry session for {len(self.retryable_failed_files_info)} file(s)...")
self.set_ui_enabled(False) # Disable UI, but cancel button will be enabled self.set_ui_enabled(False) # Disable UI, but cancel button will be enabled
@@ -3826,6 +4051,12 @@ class DownloaderApp(QWidget):
'api_url_input': job_details.get('api_url_input', ''), # Original post's API URL 'api_url_input': job_details.get('api_url_input', ''), # Original post's API URL
'manga_mode_active': job_details.get('manga_mode_active_for_file', False), 'manga_mode_active': job_details.get('manga_mode_active_for_file', False),
'manga_filename_style': job_details.get('manga_filename_style_for_file', STYLE_POST_TITLE), 'manga_filename_style': job_details.get('manga_filename_style_for_file', STYLE_POST_TITLE),
# Ensure scan_content_for_images is passed if it's part of common_args or needed
'scan_content_for_images': common_args.get('scan_content_for_images', False),
'use_cookie': common_args.get('use_cookie', False),
'cookie_text': common_args.get('cookie_text', ""),
'selected_cookie_file': common_args.get('selected_cookie_file', None),
'app_base_dir': common_args.get('app_base_dir', None),
} }
worker = PostProcessorWorker(**ppw_init_args) worker = PostProcessorWorker(**ppw_init_args)
@@ -3965,6 +4196,7 @@ class DownloaderApp(QWidget):
self.missed_title_key_terms_examples.clear() self.missed_title_key_terms_examples.clear()
self.logged_summary_for_key_term.clear() self.logged_summary_for_key_term.clear()
self.already_logged_bold_key_terms.clear() self.already_logged_bold_key_terms.clear()
if hasattr(self, 'manga_date_prefix_input'): self.manga_date_prefix_input.clear() # Clear prefix input
if self.pause_event: self.pause_event.clear() if self.pause_event: self.pause_event.clear()
self.is_paused = False # Reset pause state self.is_paused = False # Reset pause state
self.missed_key_terms_buffer.clear() self.missed_key_terms_buffer.clear()
@@ -3994,10 +4226,16 @@ class DownloaderApp(QWidget):
self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.") self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.")
self._update_manga_filename_style_button_text() self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(False) self.update_ui_for_manga_mode(False)
# Ensure scan_content_images_checkbox is reset and its state updated by thumbnail mode
if hasattr(self, 'scan_content_images_checkbox'):
self.scan_content_images_checkbox.setChecked(False)
if hasattr(self, 'download_thumbnails_checkbox'):
self._handle_thumbnail_mode_change(self.download_thumbnails_checkbox.isChecked())
def _show_feature_guide(self): def _show_feature_guide(self):
# Define content for each page # Define content for each page
page1_title = "① Introduction & Main Inputs" page1_title = "① Introduction & Main Inputs"
page1_content = """<html><head/><body> page1_content = """<html><head/><body>
<p>This guide provides an overview of the Kemono Downloader's features, fields, and buttons.</p> <p>This guide provides an overview of the Kemono Downloader's features, fields, and buttons.</p>
@@ -4076,6 +4314,7 @@ class DownloaderApp(QWidget):
<li><code>Images/GIFs</code>: Only common image formats (JPG, PNG, GIF, WEBP, etc.) and GIFs.</li> <li><code>Images/GIFs</code>: Only common image formats (JPG, PNG, GIF, WEBP, etc.) and GIFs.</li>
<li><code>Videos</code>: Only common video formats (MP4, MKV, WEBM, MOV, etc.).</li> <li><code>Videos</code>: Only common video formats (MP4, MKV, WEBM, MOV, etc.).</li>
<li><code>📦 Only Archives</code>: Exclusively downloads <b>.zip</b> and <b>.rar</b> files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show External Links' is also disabled.</li> <li><code>📦 Only Archives</code>: Exclusively downloads <b>.zip</b> and <b>.rar</b> files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show External Links' is also disabled.</li>
<li><code>🎧 Only Audio</code>: Downloads only common audio formats (MP3, WAV, FLAC, M4A, OGG, etc.). Other file-specific options behave as with 'Images' or 'Videos' mode.</li>
<li><code>🔗 Only Links</code>: 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 changes to '🔗 Extract Links'.</li> <li><code>🔗 Only Links</code>: 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 changes to '🔗 Extract Links'.</li>
</ul> </ul>
</li> </li>
@@ -4125,12 +4364,14 @@ class DownloaderApp(QWidget):
<li>The 'Page Range' input is disabled as all posts are fetched.</li> <li>The 'Page Range' input is disabled as all posts are fetched.</li>
<li>A <b>filename style toggle button</b> (e.g., 'Name: Post Title') appears in the top-right of the log area when this mode is active for a creator feed. Click it to cycle through naming styles: <li>A <b>filename style toggle button</b> (e.g., 'Name: Post Title') appears in the top-right of the log area when this mode is active for a creator feed. Click it to cycle through naming styles:
<ul> <ul>
<li><code>Name: Post Title (Default)</code>: The first file in a post is named after the post's title. Subsequent files in the same post keep original names.</li> <li><code>Name: Post Title (Default)</code>: The first file in a post is named after the post's cleaned title (e.g., 'My Chapter 1.jpg'). Subsequent files within 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.</li>
<li><code>Name: Original File</code>: All files attempt to keep their original filenames.</li> <li><code>Name: Original File</code>: All files attempt to keep their original filenames.</li>
<li><code>Name: Date Based</code>: Files are named sequentially (001.ext, 002.ext, ...) based on post publication order. Multithreading for post processing is automatically disabled for this style.</li> <li><code>Name: Original File</code>: All files attempt to keep their original filenames. When this style is active, an input field for an <b>optional filename prefix</b> (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_OriginalFile.jpg'.</li>
<li><code>Name: Title+G.Num (Post Title + Global Numbering)</code>: All files across all posts in the current download session are named sequentially using the post's cleaned title as a prefix, followed by a global counter. Example: 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.</li>
<li><code>Name: Date Based</code>: Files are named sequentially (001.ext, 002.ext, ...) based on post publication order. When this style is active, an input field for an <b>optional filename prefix</b> (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style.</li>
</ul> </ul>
</li> </li>
<li>For best results with 'Name: Post Title' or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.</li> <li>For best results with '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.</li>
</ul> </ul>
</li> </li>
</ul></li></ul> </ul></li></ul>
@@ -4158,6 +4399,18 @@ class DownloaderApp(QWidget):
</ul> </ul>
</li> </li>
<li><b> Add Button:</b> Adds the name/group from the input field above to the list and <code>Known.txt</code>.</li> <li><b> Add Button:</b> Adds the name/group from the input field above to the list and <code>Known.txt</code>.</li>
<li><b>⤵️ Add to Filter Button:</b>
<ul>
<li>Located next to the ' Add' button for the 'Known Shows/Characters' list.</li>
<li>Clicking this button opens a popup window displaying all names from your <code>Known.txt</code> file, each with a checkbox.</li>
<li>The popup includes a search bar to quickly filter the list of names.</li>
<li>You can select one or more names using the checkboxes.</li>
<li>Click 'Add Selected' to insert the chosen names into the 'Filter by Character(s)' input field in the main window.</li>
<li>If a selected name from <code>Known.txt</code> was originally a group (e.g., defined as <code>(Boa, Hancock)</code> in Known.txt), it will be added to the filter field as <code>(Boa, Hancock)~</code>. Simple names are added as-is.</li>
<li>'Select All' and 'Deselect All' buttons are available in the popup for convenience.</li>
<li>Click 'Cancel' to close the popup without any changes.</li>
</ul>
</li>
<li><b>🗑️ Delete Selected Button:</b> Deletes the selected name(s) from the list and <code>Known.txt</code>.</li> <li><b>🗑️ Delete Selected Button:</b> Deletes the selected name(s) from the list and <code>Known.txt</code>.</li>
<li><b>❓ Button (This one!):</b> Displays this comprehensive help guide.</li> <li><b>❓ Button (This one!):</b> Displays this comprehensive help guide.</li>
</ul></body></html>""" </ul></body></html>"""
@@ -4173,6 +4426,7 @@ class DownloaderApp(QWidget):
<li><b>Name: [Style] Button (Manga Filename Style):</b> <li><b>Name: [Style] Button (Manga Filename Style):</b>
<ul><li>Visible only when <b>Manga/Comic Mode</b> is active for a creator feed and not in 'Only Links' or 'Only Archives' mode.</li> <ul><li>Visible only when <b>Manga/Comic Mode</b> is active for a creator feed and not in 'Only Links' or 'Only Archives' mode.</li>
<li>Cycles through filename styles: <code>Post Title</code>, <code>Original File</code>, <code>Date Based</code>. (See Manga/Comic Mode section for details).</li> <li>Cycles through filename styles: <code>Post Title</code>, <code>Original File</code>, <code>Date Based</code>. (See Manga/Comic Mode section for details).</li>
<li>When 'Original File' or 'Date Based' style is active, an input field for an <b>optional filename prefix</b> will appear next to this button.</li>
</ul> </ul>
</li> </li>
<li><b>Multi-part: [ON/OFF] Button:</b> <li><b>Multi-part: [ON/OFF] Button:</b>
@@ -4348,6 +4602,46 @@ class DownloaderApp(QWidget):
QMessageBox.critical(self, "Error Opening File", f"Could not open '{os.path.basename(self.config_file)}':\n{e}") QMessageBox.critical(self, "Error Opening File", f"Could not open '{os.path.basename(self.config_file)}':\n{e}")
self.log_signal.emit(f"❌ Error opening '{os.path.basename(self.config_file)}': {e}") self.log_signal.emit(f"❌ Error opening '{os.path.basename(self.config_file)}': {e}")
def _show_add_to_filter_dialog(self):
global KNOWN_NAMES
if not KNOWN_NAMES:
QMessageBox.information(self, "No Known Names", "Your 'Known.txt' list is empty. Add some names first.")
return
dialog = KnownNamesFilterDialog(KNOWN_NAMES, self)
if dialog.exec_() == QDialog.Accepted:
selected_entries = dialog.get_selected_entries() # Get list of entry objects
if selected_entries:
self._add_names_to_character_filter_input(selected_entries)
def _add_names_to_character_filter_input(self, selected_entries_list):
current_filter_text = self.character_input.text().strip()
# Split existing text by comma, trim, and filter out empty strings
existing_filter_parts = [part.strip() for part in current_filter_text.split(',') if part.strip()]
# Use a set for efficient checking of existing parts (case-insensitive)
existing_parts_lower_set = {part.lower() for part in existing_filter_parts}
newly_added_parts_for_field = []
for entry_obj in selected_entries_list:
text_for_field = ""
if entry_obj.get('is_group', False):
# For groups from Known.txt, format as (alias1, alias2)~
# entry_obj['aliases'] should contain the original terms like 'Boa', 'Hancock'
aliases_str = ", ".join(sorted(entry_obj.get('aliases', []), key=str.lower))
text_for_field = f"({aliases_str})~"
else:
text_for_field = entry_obj['name']
if text_for_field.lower() not in existing_parts_lower_set:
newly_added_parts_for_field.append(text_for_field)
existing_parts_lower_set.add(text_for_field.lower())
final_filter_parts = existing_filter_parts + newly_added_parts_for_field
self.character_input.setText(", ".join(final_filter_parts))
self.log_signal.emit(f" Added to filter: {', '.join(newly_added_parts_for_field)}")
if __name__ == '__main__': if __name__ == '__main__':
import traceback import traceback
import sys # Ensure sys is imported here if not already import sys # Ensure sys is imported here if not already

View File

@@ -1,4 +1,4 @@
<h1 align="center">Kemono Downloader v4.0.0</h1> <h1 align="center">Kemono Downloader v4.1.1</h1>
<div align="center"> <div align="center">
<img src="https://github.com/Yuvi9587/Kemono-Downloader/blob/main/Read.png" alt="Kemono Downloader"/> <img src="https://github.com/Yuvi9587/Kemono-Downloader/blob/main/Read.png" alt="Kemono Downloader"/>
@@ -11,9 +11,33 @@ Built with **PyQt5**, this tool is ideal for users who want deep filtering, cust
--- ---
## What's New in v4.0.0? ## What's New in v4.1.1?
Version 3.5.0 focuses on enhancing access to content and providing even smarter organization: Version 4.1.1 introduces a smarter way to capture images that might be embedded directly within post descriptions, enhancing content discovery.
### "Scan Content for Images" Feature
- **Enhanced Image Discovery:** A new checkbox, "**Scan Content for Images**," has been added to the UI (grouped with "Download Thumbnails Only" and "Compress Large Images").
- **How it Works:**
- When enabled, the downloader scans the HTML content of posts (e.g., the description area).
- It looks for images embedded via HTML `<img>` tags or as direct absolute URL links (e.g., `https://.../image.png`).
- It intelligently resolves relative image paths found in `<img>` tags (like `/data/image.jpg`) into full, downloadable URLs.
- This is particularly useful for capturing images that are part of the post's narrative but not formally listed in the API's file or attachment sections.
- **Default State:** This option is **unchecked by default**.
- **Interaction with "Download Thumbnails Only":**
- If you check "Download Thumbnails Only":
- The "Scan Content for Images" checkbox will **automatically become checked and disabled** (locked).
- In this combined mode, the downloader will **only download images found by the content scan**. API-listed thumbnails will be ignored, prioritizing images from the post's body.
- If you uncheck "Download Thumbnails Only":
- The "Scan Content for Images" checkbox will become **enabled again and revert to being unchecked**. You can then manually enable it if you wish to scan content without being in thumbnail-only mode.
This feature ensures a more comprehensive download experience, especially for posts where images are integrated directly into the text.
---
## Previous Update: What's New in v4.0.1?
Version 4.0.1 focuses on enhancing access to content and providing even smarter organization:
### Cookie Management ### Cookie Management
@@ -71,13 +95,30 @@ This field allows for dynamic filtering for the current download session and pro
- **Adding New Names from Filters:** When you use the "Filter by Character(s)" input, if any names or groups are new (not already in `Known.txt`), a dialog will appear after you start the download. This dialog allows you to select which of these new names/groups should be added to `Known.txt`, formatted according to the rules described above. - **Adding New Names from Filters:** When you use the "Filter by Character(s)" input, if any names or groups are new (not already in `Known.txt`), a dialog will appear after you start the download. This dialog allows you to select which of these new names/groups should be added to `Known.txt`, formatted according to the rules described above.
- **Intelligent Fallback:** If "Separate Folders by Name/Title" is active, and content doesn't match the "Filter by Character(s)" UI input, the downloader consults your `Known.txt` file for folder naming. - **Intelligent Fallback:** If "Separate Folders by Name/Title" is active, and content doesn't match the "Filter by Character(s)" UI input, the downloader consults your `Known.txt` file for folder naming.
- **Direct Management:** You can add simple entries directly to `Known.txt` using the list and "Add" button in the UI's `Known.txt` management section. For creating or modifying complex grouped alias entries directly in the file, or for bulk edits, click the "Open Known.txt" button. The application reloads `Known.txt` on startup or before a download process begins. - **Direct Management:** You can add simple entries directly to `Known.txt` using the list and "Add" button in the UI's `Known.txt` management section. For creating or modifying complex grouped alias entries directly in the file, or for bulk edits, click the "Open Known.txt" button. The application reloads `Known.txt` on startup or before a download process begins.
- **Using Known Names to Populate Filters (via "Add to Filter" Button):**
- Next to the "Add" button in the `Known.txt` management section, a "⤵️ Add to Filter" button provides a quick way to use your existing known names.
- Clicking this opens a popup window displaying all entries from your `Known.txt` file, each with a checkbox.
- The popup includes:
- A search bar to quickly filter the list of names.
- "Select All" and "Deselect All" buttons for convenience.
- After selecting the desired names, click "Add Selected".
- The chosen names will be inserted into the "Filter by Character(s)" input field.
- **Important Formatting:** If a selected entry from `Known.txt` is a group (e.g., originally `(Boa Hancock)` in `Known.txt`, which implies aliases "Boa" and "Hancock"), it will be added to the filter field as `(Boa, Hancock)~`. Simple names are added as-is.
--- ---
## What's in v3.5.0? (Previous Update) ## What's in v3.5.0? (Previous Update)
This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience: This version brought significant enhancements to manga/comic downloading, filtering capabilities, and user experience:
### Enhanced Manga/Comic Mode ### Enhanced Manga/Comic Mode
- **Optional Filename Prefix:**
- When using the "Date Based" or "Original File Name" manga styles, an optional prefix can be specified in the UI.
- This prefix will be prepended to each filename generated by these styles.
- **Example (Date Based):** If prefix is `MySeries_`, files become `MySeries_001.jpg`, `MySeries_002.png`, etc.
- **Example (Original File Name):** If prefix is `Comic_Vol1_`, an original file `page_01.jpg` becomes `Comic_Vol1_page_01.jpg`.
- This input field appears automatically when either of these two manga naming styles is selected.
- **New "Date Based" Filename Style:** - **New "Date Based" Filename Style:**
- Perfect for truly sequential content! Files are named numerically (e.g., `001.jpg`, `002.jpg`, `003.ext`...) across an *entire creator's feed*, strictly following post publication order. - Perfect for truly sequential content! Files are named numerically (e.g., `001.jpg`, `002.jpg`, `003.ext`...) across an *entire creator's feed*, strictly following post publication order.
@@ -87,6 +128,13 @@ This version brings significant enhancements to manga/comic downloading, filteri
- **Guaranteed Order:** Disables multi-threading for post processing to ensure sequential accuracy. - **Guaranteed Order:** Disables multi-threading for post processing to ensure sequential accuracy.
- Works alongside the existing "Post Title" and "Original File Name" styles. - Works alongside the existing "Post Title" and "Original File Name" styles.
- **New "Title+G.Num (Post Title + Global Numbering)" Filename Style:**
- Ideal for series where you want each file to be prefixed by its post title but still maintain a global sequential number across all posts from a single download session.
- **Naming Convention:** Files are named using the cleaned post title as a prefix, followed by an underscore and a globally incrementing number (e.g., `Post Title_001.ext`, `Post Title_002.ext`).
- **Example:**
- Post "Chapter 1: The Adventure Begins" (contains 2 files: `imageA.jpg`, `imageB.png`) -> `Chapter 1 The Adventure Begins_001.jpg`, `Chapter 1 The Adventure Begins_002.png`
- Next Post "Chapter 2: New Friends" (contains 1 file: `cover.jpg`) -> `Chapter 2 New Friends_003.jpg`
- **Sequential Integrity:** Multithreading for post processing is automatically disabled when this style is selected to ensure the global numbering is strictly sequential.
--- ---
@@ -169,6 +217,7 @@ This version brings significant enhancements to manga/comic downloading, filteri
- `Nami` (simple character) - `Nami` (simple character)
- `(Boa Hancock)~` (aliases for one character, session folder "Boa Hancock", adds `(Boa Hancock)` to `Known.txt`) - `(Boa Hancock)~` (aliases for one character, session folder "Boa Hancock", adds `(Boa Hancock)` to `Known.txt`)
- `(Vivi, Uta)` (distinct characters, session folder "Vivi Uta", adds `Vivi` and `Uta` separately to `Known.txt`) - `(Vivi, Uta)` (distinct characters, session folder "Vivi Uta", adds `Vivi` and `Uta` separately to `Known.txt`)
- A "⤵️ Add to Filter" button (near the `Known.txt` management UI) allows you to quickly populate this field by selecting from your existing `Known.txt` entries via a popup with search and checkbox selection.
- See "Advanced `Known.txt` and Character Filtering" for full details. - See "Advanced `Known.txt` and Character Filtering" for full details.
- **Filter Scopes:** - **Filter Scopes:**
- `Files` - `Files`
@@ -200,6 +249,7 @@ This version brings significant enhancements to manga/comic downloading, filteri
- `Name: Post Title (Default)` - `Name: Post Title (Default)`
- `Name: Original File` - `Name: Original File`
- `Name: Date Based (New)` - `Name: Date Based (New)`
- `Name: Title+G.Num (Post Title + Global Numbering)`
- **Best With:** Character filters set to manga/series title - **Best With:** Character filters set to manga/series title
@@ -217,12 +267,17 @@ This version brings significant enhancements to manga/comic downloading, filteri
--- ---
### Thumbnail & Compression Tools ### Thumbnail & Compression Tools
- **Download Thumbnails Only:**
- **Download Thumbnails Only** - Downloads small preview images from the API instead of full-sized files (if available).
- **Interaction with "Scan Content for Images" (New in v4.1.1):** When "Download Thumbnails Only" is active, "Scan Content for Images" is auto-enabled, and only images found by the content scan are downloaded. See "What's New in v4.1.1" for details.
- **Scan Content for Images (New in v4.1.1):**
- A UI option to scan the HTML content of posts for embedded image URLs (from `<img>` tags or direct links).
- Resolves relative paths and helps capture images not listed in the API's formal attachments.
- See the "What's New in v4.1.1?" section for a comprehensive explanation.
- **Compress to WebP** (via Pillow) - **Compress to WebP** (via Pillow)
- Converts large images to smaller WebP versions - Converts large images to smaller WebP versions
--- ---
### Performance Features ### Performance Features