mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67faea0992 | ||
|
|
be03f914ef | ||
|
|
ec9900b90f | ||
|
|
55ebfdb980 | ||
|
|
4a93b721e2 | ||
|
|
257111d462 | ||
|
|
9563ce82db | ||
|
|
169ded3fd8 | ||
|
|
7e8e8a59e2 | ||
|
|
0acd433920 | ||
|
|
cef4211d7b |
@@ -1,8 +1,6 @@
|
||||
# src/core/Hentai2read_client.py
|
||||
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
import time
|
||||
import cloudscraper
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urljoin
|
||||
@@ -65,12 +63,37 @@ def run_hentai2read_download(start_url, output_dir, progress_callback, overall_p
|
||||
def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
"""
|
||||
Scrapes the main series page to get the Artist Name, Series Title, and chapter list.
|
||||
Includes a retry mechanism for the initial connection.
|
||||
"""
|
||||
try:
|
||||
response = scraper.get(start_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
max_retries = 4 # Total number of attempts (1 initial + 3 retries)
|
||||
last_exception = None
|
||||
soup = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if attempt > 0:
|
||||
progress_callback(f" [Hentai2Read] ⚠️ Retrying connection (Attempt {attempt + 1}/{max_retries})...")
|
||||
|
||||
response = scraper.get(start_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# If successful, clear exception and break the loop
|
||||
last_exception = None
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
progress_callback(f" [Hentai2Read] ⚠️ Connection attempt {attempt + 1} failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2 * (attempt + 1)) # Wait 2s, 4s, 6s
|
||||
continue # Try again
|
||||
|
||||
if last_exception:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata after {max_retries} attempts: {last_exception}")
|
||||
return "Unknown Series", []
|
||||
|
||||
try:
|
||||
series_title = "Unknown Series"
|
||||
artist_name = None
|
||||
metadata_list = soup.select_one("ul.list.list-simple-mini")
|
||||
@@ -107,10 +130,9 @@ def _get_series_metadata(start_url, progress_callback, scraper):
|
||||
return top_level_folder_name, chapters_to_process
|
||||
|
||||
except Exception as e:
|
||||
progress_callback(f" [Hentai2Read] ❌ Error getting series metadata: {e}")
|
||||
progress_callback(f" [Hentai2Read] ❌ Error parsing metadata after successful connection: {e}")
|
||||
return "Unknown Series", []
|
||||
|
||||
### NEW: This function contains the pipeline logic ###
|
||||
def _process_and_download_chapter(chapter_url, save_path, scraper, progress_callback, check_pause_func):
|
||||
"""
|
||||
Uses a producer-consumer pattern to download a chapter.
|
||||
@@ -120,12 +142,10 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
task_queue = queue.Queue()
|
||||
num_download_threads = 8
|
||||
|
||||
# These will be updated by the worker threads
|
||||
download_stats = {'downloaded': 0, 'skipped': 0}
|
||||
|
||||
def downloader_worker():
|
||||
"""The function that each download thread will run."""
|
||||
# Create a unique session for each thread to avoid conflicts
|
||||
worker_scraper = cloudscraper.create_scraper()
|
||||
while True:
|
||||
try:
|
||||
@@ -153,12 +173,10 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
finally:
|
||||
task_queue.task_done()
|
||||
|
||||
# --- Start the downloader threads ---
|
||||
executor = ThreadPoolExecutor(max_workers=num_download_threads, thread_name_prefix='H2R_Downloader')
|
||||
for _ in range(num_download_threads):
|
||||
executor.submit(downloader_worker)
|
||||
|
||||
# --- Main thread acts as the scraper (producer) ---
|
||||
page_number = 1
|
||||
while True:
|
||||
if check_pause_func(): break
|
||||
@@ -168,12 +186,25 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
|
||||
page_url_to_check = f"{chapter_url}{page_number}/"
|
||||
try:
|
||||
response = scraper.get(page_url_to_check, timeout=30)
|
||||
if response.history or response.status_code != 200:
|
||||
page_response = None
|
||||
page_last_exception = None
|
||||
for page_attempt in range(3): # 3 attempts for sub-pages
|
||||
try:
|
||||
page_response = scraper.get(page_url_to_check, timeout=30)
|
||||
page_last_exception = None
|
||||
break
|
||||
except Exception as e:
|
||||
page_last_exception = e
|
||||
time.sleep(1) # Short delay for page scraping retries
|
||||
|
||||
if page_last_exception:
|
||||
raise page_last_exception # Give up after 3 tries
|
||||
|
||||
if page_response.history or page_response.status_code != 200:
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected on page {page_number}.")
|
||||
break
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
soup = BeautifulSoup(page_response.text, 'html.parser')
|
||||
img_tag = soup.select_one("img#arf-reader")
|
||||
img_src = img_tag.get("src") if img_tag else None
|
||||
|
||||
@@ -181,12 +212,11 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
progress_callback(f" [Hentai2Read] End of chapter detected (Placeholder image on page {page_number}).")
|
||||
break
|
||||
|
||||
normalized_img_src = urljoin(response.url, img_src)
|
||||
normalized_img_src = urljoin(page_response.url, img_src)
|
||||
ext = os.path.splitext(normalized_img_src.split('/')[-1])[-1] or ".jpg"
|
||||
filename = f"{page_number:03d}{ext}"
|
||||
filepath = os.path.join(save_path, filename)
|
||||
|
||||
# Put the download task into the queue for a worker to pick up
|
||||
task_queue.put((filepath, normalized_img_src))
|
||||
|
||||
page_number += 1
|
||||
@@ -195,12 +225,9 @@ def _process_and_download_chapter(chapter_url, save_path, scraper, progress_call
|
||||
progress_callback(f" [Hentai2Read] ❌ Error while scraping page {page_number}: {e}")
|
||||
break
|
||||
|
||||
# --- Shutdown sequence ---
|
||||
# Tell all worker threads to exit by sending the sentinel value
|
||||
for _ in range(num_download_threads):
|
||||
task_queue.put(None)
|
||||
|
||||
# Wait for all download tasks to be completed
|
||||
executor.shutdown(wait=True)
|
||||
|
||||
progress_callback(f" Found and processed {page_number - 1} images for this chapter.")
|
||||
|
||||
@@ -159,8 +159,6 @@ def download_from_api(
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Download_from_api cancelled at start.")
|
||||
return
|
||||
|
||||
# The code that defined api_domain was moved from here to the top of the function
|
||||
|
||||
if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'kemono.cr', 'coomer.su', 'coomer.party', 'coomer.st']):
|
||||
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
|
||||
@@ -312,6 +310,8 @@ def download_from_api(
|
||||
current_offset = (start_page - 1) * page_size
|
||||
current_page_num = start_page
|
||||
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
|
||||
|
||||
# --- START OF MODIFIED BLOCK ---
|
||||
while True:
|
||||
if pause_event and pause_event.is_set():
|
||||
logger(" Post fetching loop paused...")
|
||||
@@ -321,18 +321,23 @@ def download_from_api(
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
|
||||
|
||||
if cancellation_event and cancellation_event.is_set():
|
||||
logger(" Post fetching loop cancelled.")
|
||||
break
|
||||
|
||||
if target_post_id and processed_target_post_flag:
|
||||
break
|
||||
|
||||
if not target_post_id and end_page and current_page_num > end_page:
|
||||
logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
|
||||
break
|
||||
|
||||
try:
|
||||
posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
if not isinstance(posts_batch, list):
|
||||
logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||
# 1. Fetch the raw batch of posts
|
||||
raw_posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
|
||||
if not isinstance(raw_posts_batch, list):
|
||||
logger(f"❌ API Error: Expected list of posts, got {type(raw_posts_batch)} at page {current_page_num} (offset {current_offset}).")
|
||||
break
|
||||
except RuntimeError as e:
|
||||
if "cancelled by user" in str(e).lower():
|
||||
@@ -344,14 +349,9 @@ def download_from_api(
|
||||
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
if processed_post_ids:
|
||||
original_count = len(posts_batch)
|
||||
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
|
||||
if not posts_batch:
|
||||
|
||||
# 2. Check if the *raw* batch from the API was empty. This is the correct "end" condition.
|
||||
if not raw_posts_batch:
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
|
||||
elif not target_post_id:
|
||||
@@ -359,20 +359,45 @@ def download_from_api(
|
||||
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
|
||||
else:
|
||||
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
|
||||
break
|
||||
break # This break is now correct.
|
||||
|
||||
# 3. Filter the batch against processed IDs
|
||||
posts_batch_to_yield = raw_posts_batch
|
||||
original_count = len(raw_posts_batch)
|
||||
|
||||
if processed_post_ids:
|
||||
posts_batch_to_yield = [post for post in raw_posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch_to_yield)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
|
||||
# 4. Process the *filtered* batch
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
|
||||
# Still searching for a specific post
|
||||
matching_post = next((p for p in posts_batch_to_yield if str(p.get('id')) == str(target_post_id)), None)
|
||||
if matching_post:
|
||||
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
|
||||
yield [matching_post]
|
||||
processed_target_post_flag = True
|
||||
elif not target_post_id:
|
||||
yield posts_batch
|
||||
# Downloading a creator feed
|
||||
if posts_batch_to_yield:
|
||||
# We found new posts on this page, yield them
|
||||
yield posts_batch_to_yield
|
||||
elif original_count > 0:
|
||||
# We found 0 new posts, but the page *did* have posts (they were just skipped).
|
||||
# Log this and continue to the next page.
|
||||
logger(f" No new posts found on page {current_page_num}. Checking next page...")
|
||||
# If original_count was 0, the `if not raw_posts_batch:` check
|
||||
# already caught it and broke the loop.
|
||||
|
||||
if processed_target_post_flag:
|
||||
break
|
||||
|
||||
current_offset += page_size
|
||||
current_page_num += 1
|
||||
time.sleep(0.6)
|
||||
# --- END OF MODIFIED BLOCK ---
|
||||
|
||||
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
|
||||
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")
|
||||
|
||||
|
||||
@@ -69,15 +69,28 @@ def fetch_fap_nation_data(album_url, logger_func):
|
||||
|
||||
if direct_links_found:
|
||||
logger_func(f" [Fap-Nation] Found {len(direct_links_found)} direct media link(s). Selecting the best quality...")
|
||||
best_link = direct_links_found[0]
|
||||
for link in direct_links_found:
|
||||
if '1080p' in link.lower():
|
||||
best_link = link
|
||||
break
|
||||
best_link = None
|
||||
# Define qualities from highest to lowest
|
||||
qualities_to_check = ['1080p', '720p', '480p', '360p']
|
||||
|
||||
# Find the best quality link by iterating through preferred qualities
|
||||
for quality in qualities_to_check:
|
||||
for link in direct_links_found:
|
||||
if quality in link.lower():
|
||||
best_link = link
|
||||
logger_func(f" [Fap-Nation] Found '{quality}' link: {best_link}")
|
||||
break # Found the best link for this quality level
|
||||
if best_link:
|
||||
break # Found the highest quality available
|
||||
|
||||
# Fallback if no quality string was found in any link
|
||||
if not best_link:
|
||||
best_link = direct_links_found[0]
|
||||
logger_func(f" [Fap-Nation] ⚠️ No quality tags (1080p, 720p, etc.) found in links. Defaulting to first link: {best_link}")
|
||||
|
||||
final_url = best_link
|
||||
link_type = 'direct'
|
||||
logger_func(f" [Fap-Nation] Identified direct media link: {final_url}")
|
||||
|
||||
# If after all checks, we still have no URL, then fail.
|
||||
if not final_url:
|
||||
logger_func(" [Fap-Nation] ❌ Stage 1 Failed: Could not find any HLS stream or direct link.")
|
||||
|
||||
@@ -52,7 +52,7 @@ from ..utils.file_utils import (
|
||||
from ..utils.network_utils import prepare_cookies_for_request, get_link_platform
|
||||
from ..utils.text_utils import (
|
||||
is_title_match_for_character, is_filename_match_for_character, strip_html_tags,
|
||||
extract_folder_name_from_title, # This was the function causing the error
|
||||
extract_folder_name_from_title,
|
||||
match_folders_from_title, match_folders_from_filename_enhanced
|
||||
)
|
||||
from ..config.constants import *
|
||||
@@ -1810,6 +1810,31 @@ class PostProcessorWorker:
|
||||
|
||||
if not all_files_from_post_api:
|
||||
self.logger(f" No files found to download for post {post_id}.")
|
||||
if not self.extract_links_only and should_create_post_subfolder:
|
||||
path_to_check_for_emptiness = determined_post_save_path_for_history
|
||||
try:
|
||||
if os.path.isdir(path_to_check_for_emptiness):
|
||||
dir_contents = os.listdir(path_to_check_for_emptiness)
|
||||
# Check if the directory is empty OR only contains our ID file
|
||||
is_effectively_empty = True
|
||||
if dir_contents:
|
||||
if not all(f.startswith('.postid_') for f in dir_contents):
|
||||
is_effectively_empty = False
|
||||
|
||||
if is_effectively_empty:
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder (post had no files): '{path_to_check_for_emptiness}'")
|
||||
if dir_contents:
|
||||
for id_file in dir_contents:
|
||||
if id_file.startswith('.postid_'):
|
||||
try:
|
||||
os.remove(os.path.join(path_to_check_for_emptiness, id_file))
|
||||
except OSError as e_rm_id:
|
||||
self.logger(f" ⚠️ Could not remove ID file '{id_file}' during cleanup: {e_rm_id}")
|
||||
os.rmdir(path_to_check_for_emptiness)
|
||||
except OSError as e_rmdir:
|
||||
self.logger(f" ⚠️ Could not remove effectively empty subfolder (no files) '{path_to_check_for_emptiness}': {e_rmdir}")
|
||||
# --- END NEW CLEANUP LOGIC ---
|
||||
|
||||
history_data_for_no_files_post = {
|
||||
'post_title': post_title,
|
||||
'post_id': post_id,
|
||||
@@ -1823,7 +1848,7 @@ class PostProcessorWorker:
|
||||
result_tuple = (0, 0, [], [], [], history_data_for_no_files_post, None)
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
|
||||
|
||||
files_to_download_info_list = []
|
||||
processed_original_filenames_in_this_post = set()
|
||||
if self.keep_in_post_duplicates:
|
||||
@@ -2052,9 +2077,27 @@ class PostProcessorWorker:
|
||||
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
|
||||
path_to_check_for_emptiness = determined_post_save_path_for_history
|
||||
try:
|
||||
if os.path.isdir(path_to_check_for_emptiness) and not os.listdir(path_to_check_for_emptiness):
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'")
|
||||
os.rmdir(path_to_check_for_emptiness)
|
||||
if os.path.isdir(path_to_check_for_emptiness):
|
||||
dir_contents = os.listdir(path_to_check_for_emptiness)
|
||||
# Check if the directory is empty OR only contains our ID file
|
||||
is_effectively_empty = True
|
||||
if dir_contents:
|
||||
# If there are files, check if ALL of them are .postid files
|
||||
if not all(f.startswith('.postid_') for f in dir_contents):
|
||||
is_effectively_empty = False
|
||||
|
||||
if is_effectively_empty:
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder (no files downloaded): '{path_to_check_for_emptiness}'")
|
||||
# We must first remove the ID file(s) before removing the dir
|
||||
if dir_contents:
|
||||
for id_file in dir_contents:
|
||||
if id_file.startswith('.postid_'):
|
||||
try:
|
||||
os.remove(os.path.join(path_to_check_for_emptiness, id_file))
|
||||
except OSError as e_rm_id:
|
||||
self.logger(f" ⚠️ Could not remove ID file '{id_file}' during cleanup: {e_rm_id}")
|
||||
|
||||
os.rmdir(path_to_check_for_emptiness) # Now the rmdir should work
|
||||
except OSError as e_rmdir:
|
||||
self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
|
||||
|
||||
@@ -2066,11 +2109,29 @@ class PostProcessorWorker:
|
||||
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
|
||||
path_to_check_for_emptiness = determined_post_save_path_for_history
|
||||
try:
|
||||
if os.path.isdir(path_to_check_for_emptiness) and not os.listdir(path_to_check_for_emptiness):
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'")
|
||||
os.rmdir(path_to_check_for_emptiness)
|
||||
if os.path.isdir(path_to_check_for_emptiness):
|
||||
dir_contents = os.listdir(path_to_check_for_emptiness)
|
||||
# Check if the directory is empty OR only contains our ID file
|
||||
is_effectively_empty = True
|
||||
if dir_contents:
|
||||
# If there are files, check if ALL of them are .postid files
|
||||
if not all(f.startswith('.postid_') for f in dir_contents):
|
||||
is_effectively_empty = False
|
||||
|
||||
if is_effectively_empty:
|
||||
self.logger(f" 🗑️ Removing empty post-specific subfolder (no files downloaded): '{path_to_check_for_emptiness}'")
|
||||
# We must first remove the ID file(s) before removing the dir
|
||||
if dir_contents:
|
||||
for id_file in dir_contents:
|
||||
if id_file.startswith('.postid_'):
|
||||
try:
|
||||
os.remove(os.path.join(path_to_check_for_emptiness, id_file))
|
||||
except OSError as e_rm_id:
|
||||
self.logger(f" ⚠️ Could not remove ID file '{id_file}' during cleanup: {e_rm_id}")
|
||||
|
||||
os.rmdir(path_to_check_for_emptiness) # Now the rmdir should work
|
||||
except OSError as e_rmdir:
|
||||
self.logger(f" ⚠️ Could not remove potentially empty subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
|
||||
self.logger(f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
|
||||
|
||||
self._emit_signal('worker_finished', result_tuple)
|
||||
return result_tuple
|
||||
|
||||
@@ -145,7 +145,9 @@ def download_and_decrypt_mega_file(info, download_path, logger_func, progress_ca
|
||||
logger_func(f" [Mega] Download for '{file_name}' cancelled before starting.")
|
||||
return
|
||||
|
||||
if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:
|
||||
|
||||
# i tried to make the mega download multipart for big file but it didnt work you can try if you can fix this to make it multipart replace "if true" with this "if file_size < MIN_SIZE_FOR_MULTIPART_MEGA:" to activate multipart
|
||||
if True:
|
||||
logger_func(f" [Mega] Downloading '{file_name}' (Single Stream)...")
|
||||
try:
|
||||
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce, initial_value=0)
|
||||
|
||||
@@ -6,7 +6,7 @@ from packaging.version import parse as parse_version
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
|
||||
# Constants for the updater
|
||||
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi63771/Kemono-Downloader/releases/latest"
|
||||
GITHUB_REPO_URL = "https://api.github.com/repos/Yuvi9587/Kemono-Downloader/releases/latest"
|
||||
EXE_NAME = "Kemono.Downloader.exe"
|
||||
|
||||
class UpdateChecker(QThread):
|
||||
|
||||
@@ -2,32 +2,38 @@ import re
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Utility Imports
|
||||
from ...utils.network_utils import prepare_cookies_for_request
|
||||
from ...utils.file_utils import clean_folder_name
|
||||
from ...utils.file_utils import clean_folder_name
|
||||
|
||||
# Downloader Thread Imports (Alphabetical Order Recommended)
|
||||
from .allcomic_downloader_thread import AllcomicDownloadThread
|
||||
from .booru_downloader_thread import BooruDownloadThread
|
||||
from .bunkr_downloader_thread import BunkrDownloadThread
|
||||
from .discord_downloader_thread import DiscordDownloadThread
|
||||
from .discord_downloader_thread import DiscordDownloadThread # Official Discord
|
||||
from .drive_downloader_thread import DriveDownloadThread
|
||||
from .erome_downloader_thread import EromeDownloadThread
|
||||
from .external_link_downloader_thread import ExternalLinkDownloadThread
|
||||
from .fap_nation_downloader_thread import FapNationDownloadThread
|
||||
from .hentai2read_downloader_thread import Hentai2readDownloadThread
|
||||
from .kemono_discord_downloader_thread import KemonoDiscordDownloadThread
|
||||
from .mangadex_downloader_thread import MangaDexDownloadThread
|
||||
from .nhentai_downloader_thread import NhentaiDownloadThread
|
||||
from .pixeldrain_downloader_thread import PixeldrainDownloadThread
|
||||
from .rule34video_downloader_thread import Rule34VideoDownloadThread
|
||||
from .saint2_downloader_thread import Saint2DownloadThread
|
||||
from .simp_city_downloader_thread import SimpCityDownloadThread
|
||||
from .toonily_downloader_thread import ToonilyDownloadThread
|
||||
from .rule34video_downloader_thread import Rule34VideoDownloadThread
|
||||
|
||||
|
||||
def create_downloader_thread(main_app, api_url, service, id1, id2, effective_output_dir_for_run):
|
||||
"""
|
||||
Factory function to create and configure the correct QThread for a given URL.
|
||||
Returns a configured QThread instance or None if no special handler is found.
|
||||
Returns a configured QThread instance, a specific error string ("COOKIE_ERROR", "FETCH_ERROR"),
|
||||
or None if no special handler is found (indicating fallback to generic BackendDownloadThread).
|
||||
"""
|
||||
|
||||
|
||||
# Handler for Booru sites (Danbooru, Gelbooru)
|
||||
if service in ['danbooru', 'gelbooru']:
|
||||
api_key = main_app.api_key_input.text().strip()
|
||||
@@ -37,7 +43,7 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
|
||||
api_key=api_key, user_id=user_id, parent=main_app
|
||||
)
|
||||
|
||||
# Handler for cloud storage sites (Mega, GDrive, etc.)
|
||||
# Handler for cloud storage sites (Mega, GDrive, Dropbox, GoFile)
|
||||
platform = None
|
||||
if 'mega.nz' in api_url or 'mega.io' in api_url: platform = 'mega'
|
||||
elif 'drive.google.com' in api_url: platform = 'gdrive'
|
||||
@@ -47,7 +53,8 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
|
||||
use_post_subfolder = main_app.use_subfolder_per_post_checkbox.isChecked()
|
||||
return DriveDownloadThread(
|
||||
api_url, effective_output_dir_for_run, platform, use_post_subfolder,
|
||||
main_app.cancellation_event, main_app.pause_event, main_app.log_signal.emit
|
||||
main_app.cancellation_event, main_app.pause_event, main_app.log_signal.emit,
|
||||
parent=main_app # Pass parent for consistency
|
||||
)
|
||||
|
||||
# Handler for Erome
|
||||
@@ -59,75 +66,118 @@ def create_downloader_thread(main_app, api_url, service, id1, id2, effective_out
|
||||
return MangaDexDownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for Saint2
|
||||
is_saint2_url = 'saint2.su' in api_url or 'saint2.pk' in api_url
|
||||
if is_saint2_url and api_url.strip().lower() != 'saint2.su': # Exclude batch mode trigger
|
||||
is_saint2_url = service == 'saint2' or 'saint2.su' in api_url or 'saint2.pk' in api_url # Add more domains if needed
|
||||
if is_saint2_url and api_url.strip().lower() != 'saint2.su': # Exclude batch mode trigger if using URL input
|
||||
return Saint2DownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for SimpCity
|
||||
if service == 'simpcity':
|
||||
cookies = prepare_cookies_for_request(
|
||||
use_cookie_flag=True, cookie_text_input=main_app.cookie_text_input.text(),
|
||||
selected_cookie_file_path=main_app.selected_cookie_filepath,
|
||||
app_base_dir=main_app.app_base_dir, logger_func=main_app.log_signal.emit,
|
||||
target_domain='simpcity.cr'
|
||||
use_cookie_flag=True, # SimpCity requires cookies
|
||||
cookie_text_input=main_app.simpcity_cookie_text_input.text(), # Use dedicated input
|
||||
selected_cookie_file_path=main_app.selected_cookie_filepath, # Use shared selection
|
||||
app_base_dir=main_app.app_base_dir,
|
||||
logger_func=main_app.log_signal.emit,
|
||||
target_domain='simpcity.cr' # Specific domain
|
||||
)
|
||||
if not cookies:
|
||||
# The main app will handle the error dialog
|
||||
return "COOKIE_ERROR"
|
||||
main_app.log_signal.emit("❌ SimpCity requires valid cookies. Please provide them.")
|
||||
return "COOKIE_ERROR" # Sentinel value for cookie failure
|
||||
return SimpCityDownloadThread(api_url, id2, effective_output_dir_for_run, cookies, main_app)
|
||||
|
||||
# Handler for Rule34Video
|
||||
if service == 'rule34video':
|
||||
main_app.log_signal.emit("ℹ️ Rule34Video.com URL detected. Starting dedicated downloader.")
|
||||
# id1 contains the video_id from extract_post_info
|
||||
return Rule34VideoDownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
return Rule34VideoDownloadThread(api_url, effective_output_dir_for_run, main_app) # id1 (video_id) is used inside the thread
|
||||
|
||||
# Handler for official Discord URLs
|
||||
if 'discord.com' in api_url and service == 'discord':
|
||||
token = main_app.remove_from_filename_input.text().strip()
|
||||
limit_text = main_app.discord_message_limit_input.text().strip()
|
||||
message_limit = int(limit_text) if limit_text else None
|
||||
mode = 'pdf' if main_app.discord_download_scope == 'messages' else 'files'
|
||||
return DiscordDownloadThread(
|
||||
mode=mode, session=requests.Session(), token=token, output_dir=effective_output_dir_for_run,
|
||||
server_id=id1, channel_id=id2, url=api_url, app_base_dir=main_app.app_base_dir,
|
||||
limit=message_limit, parent=main_app
|
||||
# HANDLER FOR KEMONO DISCORD (Place BEFORE official Discord)
|
||||
elif service == 'discord' and any(domain in api_url for domain in ['kemono.cr', 'kemono.su', 'kemono.party']):
|
||||
main_app.log_signal.emit("ℹ️ Kemono Discord URL detected. Starting dedicated downloader.")
|
||||
cookies = prepare_cookies_for_request(
|
||||
use_cookie_flag=main_app.use_cookie_checkbox.isChecked(), # Respect UI setting
|
||||
cookie_text_input=main_app.cookie_text_input.text(),
|
||||
selected_cookie_file_path=main_app.selected_cookie_filepath,
|
||||
app_base_dir=main_app.app_base_dir,
|
||||
logger_func=main_app.log_signal.emit,
|
||||
target_domain='kemono.cr' # Primary Kemono domain, adjust if needed
|
||||
)
|
||||
# KemonoDiscordDownloadThread expects parent for events
|
||||
return KemonoDiscordDownloadThread(
|
||||
server_id=id1,
|
||||
channel_id=id2,
|
||||
output_dir=effective_output_dir_for_run,
|
||||
cookies_dict=cookies,
|
||||
parent=main_app
|
||||
)
|
||||
|
||||
# Handler for Allcomic/Allporncomic
|
||||
if 'allcomic.com' in api_url or 'allporncomic.com' in api_url:
|
||||
# Handler for official Discord URLs
|
||||
elif service == 'discord' and 'discord.com' in api_url:
|
||||
main_app.log_signal.emit("ℹ️ Official Discord URL detected. Starting dedicated downloader.")
|
||||
token = main_app.remove_from_filename_input.text().strip() # Token is in the "Remove Words" field for Discord
|
||||
if not token:
|
||||
main_app.log_signal.emit("❌ Official Discord requires an Authorization Token in the 'Remove Words' field.")
|
||||
return None # Or a specific error sentinel
|
||||
|
||||
limit_text = main_app.discord_message_limit_input.text().strip()
|
||||
message_limit = int(limit_text) if limit_text.isdigit() else None
|
||||
mode = main_app.discord_download_scope # Should be 'pdf' or 'files'
|
||||
|
||||
return DiscordDownloadThread(
|
||||
mode=mode,
|
||||
session=requests.Session(), # Create a session for this thread
|
||||
token=token,
|
||||
output_dir=effective_output_dir_for_run,
|
||||
server_id=id1,
|
||||
channel_id=id2,
|
||||
url=api_url,
|
||||
app_base_dir=main_app.app_base_dir,
|
||||
limit=message_limit,
|
||||
parent=main_app # Pass main_app for events/signals
|
||||
)
|
||||
|
||||
# Check specific domains or rely on service name if extract_post_info provides it
|
||||
if service == 'allcomic' or 'allcomic.com' in api_url or 'allporncomic.com' in api_url:
|
||||
return AllcomicDownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for Hentai2Read
|
||||
if 'hentai2read.com' in api_url:
|
||||
if service == 'hentai2read' or 'hentai2read.com' in api_url:
|
||||
return Hentai2readDownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for Fap-Nation
|
||||
if 'fap-nation.com' in api_url or 'fap-nation.org' in api_url:
|
||||
if service == 'fap-nation' or 'fap-nation.com' in api_url or 'fap-nation.org' in api_url:
|
||||
use_post_subfolder = main_app.use_subfolder_per_post_checkbox.isChecked()
|
||||
# Ensure signals are passed correctly if needed by the thread
|
||||
return FapNationDownloadThread(
|
||||
api_url, effective_output_dir_for_run, use_post_subfolder,
|
||||
main_app.pause_event, main_app.cancellation_event, main_app.actual_gui_signals, main_app
|
||||
)
|
||||
|
||||
# Handler for Pixeldrain
|
||||
if 'pixeldrain.com' in api_url:
|
||||
return PixeldrainDownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
if service == 'pixeldrain' or 'pixeldrain.com' in api_url:
|
||||
return PixeldrainDownloadThread(api_url, effective_output_dir_for_run, main_app) # URL contains the ID
|
||||
|
||||
# Handler for nHentai
|
||||
if service == 'nhentai':
|
||||
from ...core.nhentai_client import fetch_nhentai_gallery
|
||||
main_app.log_signal.emit(f"ℹ️ nHentai gallery ID {id1} detected. Fetching gallery data...")
|
||||
gallery_data = fetch_nhentai_gallery(id1, main_app.log_signal.emit)
|
||||
if not gallery_data:
|
||||
main_app.log_signal.emit(f"❌ Failed to fetch nHentai gallery data for ID {id1}.")
|
||||
return "FETCH_ERROR" # Sentinel value for fetch failure
|
||||
return NhentaiDownloadThread(gallery_data, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for Toonily
|
||||
if 'toonily.com' in api_url:
|
||||
if service == 'toonily' or 'toonily.com' in api_url:
|
||||
return ToonilyDownloadThread(api_url, effective_output_dir_for_run, main_app)
|
||||
|
||||
# Handler for Bunkr
|
||||
if service == 'bunkr':
|
||||
# id1 contains the full URL or album ID from extract_post_info
|
||||
return BunkrDownloadThread(id1, effective_output_dir_for_run, main_app)
|
||||
|
||||
# If no special handler matched, return None
|
||||
# --- Fallback ---
|
||||
# If no specific handler matched based on service name or URL pattern, return None.
|
||||
# This signals main_window.py to use the generic BackendDownloadThread/PostProcessorWorker
|
||||
# which uses the standard Kemono/Coomer post API.
|
||||
main_app.log_signal.emit(f"ℹ️ No specialized downloader found for service '{service}' and URL '{api_url[:50]}...'. Using generic downloader.")
|
||||
return None
|
||||
549
src/ui/classes/kemono_discord_downloader_thread.py
Normal file
549
src/ui/classes/kemono_discord_downloader_thread.py
Normal file
@@ -0,0 +1,549 @@
|
||||
# kemono_discord_downloader_thread.py
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
import cloudscraper
|
||||
import requests
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from PyQt5.QtCore import QThread, pyqtSignal
|
||||
|
||||
# --- Assuming these files are in the correct relative path ---
|
||||
# Adjust imports if your project structure is different
|
||||
try:
|
||||
from ...core.discord_client import fetch_server_channels, fetch_channel_messages
|
||||
from ...utils.file_utils import clean_filename
|
||||
except ImportError as e:
|
||||
# Basic fallback logging if signals aren't ready
|
||||
print(f"ERROR: Failed to import required modules for Kemono Discord thread: {e}")
|
||||
# Re-raise to prevent the thread from being created incorrectly
|
||||
raise
|
||||
|
||||
# Custom exception for clean cancellation/pausing
|
||||
class InterruptedError(Exception):
|
||||
"""Custom exception for handling cancellations/pausing gracefully within download loops."""
|
||||
pass
|
||||
|
||||
class KemonoDiscordDownloadThread(QThread):
|
||||
"""
|
||||
A dedicated QThread for downloading files from Kemono Discord server/channel pages,
|
||||
using the Kemono API via discord_client and multithreading for file downloads.
|
||||
Includes a single retry attempt after a 15-second delay for specific errors.
|
||||
"""
|
||||
# --- Signals ---
|
||||
progress_signal = pyqtSignal(str) # General log messages
|
||||
progress_label_signal = pyqtSignal(str) # Update main progress label (e.g., "Fetching messages...")
|
||||
file_progress_signal = pyqtSignal(str, object) # Update file progress bar (filename, (downloaded_bytes, total_bytes | None))
|
||||
permanent_file_failed_signal = pyqtSignal(list) # To report failures to main window
|
||||
finished_signal = pyqtSignal(int, int, bool, list) # (downloaded_count, skipped_count, was_cancelled, [])
|
||||
|
||||
def __init__(self, server_id, channel_id, output_dir, cookies_dict, parent):
|
||||
"""
|
||||
Initializes the Kemono Discord downloader thread.
|
||||
|
||||
Args:
|
||||
server_id (str): The Discord server ID from Kemono.
|
||||
channel_id (str | None): The specific Discord channel ID from Kemono, if provided.
|
||||
output_dir (str): The base directory to save downloaded files.
|
||||
cookies_dict (dict | None): Cookies to use for requests.
|
||||
parent (QWidget): The parent widget (main_app) to access events/settings.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.server_id = server_id
|
||||
self.target_channel_id = channel_id # The specific channel from URL, if any
|
||||
self.output_dir = output_dir
|
||||
self.cookies_dict = cookies_dict
|
||||
self.parent_app = parent # Access main app's events and settings
|
||||
|
||||
# --- Shared Events & Internal State ---
|
||||
self.cancellation_event = getattr(parent, 'cancellation_event', threading.Event())
|
||||
self.pause_event = getattr(parent, 'pause_event', threading.Event())
|
||||
self._is_cancelled_internal = False # Internal flag for quick breaking
|
||||
|
||||
# --- Thread-Safe Counters ---
|
||||
self.download_count = 0
|
||||
self.skip_count = 0
|
||||
self.count_lock = threading.Lock()
|
||||
|
||||
# --- List to Store Failure Details ---
|
||||
self.permanently_failed_details = []
|
||||
|
||||
# --- Multithreading Configuration ---
|
||||
self.num_file_threads = 1 # Default
|
||||
try:
|
||||
use_mt = getattr(self.parent_app, 'use_multithreading_checkbox', None)
|
||||
thread_input = getattr(self.parent_app, 'thread_count_input', None)
|
||||
if use_mt and use_mt.isChecked() and thread_input:
|
||||
thread_count_ui = int(thread_input.text().strip())
|
||||
# Apply a reasonable cap specific to this downloader type (adjust as needed)
|
||||
self.num_file_threads = max(1, min(thread_count_ui, 20)) # Cap at 20 file threads
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
try: self.progress_signal.emit("⚠️ Warning: Could not read thread count setting, defaulting to 1.")
|
||||
except: pass
|
||||
self.num_file_threads = 1 # Fallback on error getting setting
|
||||
|
||||
# --- Network Client ---
|
||||
try:
|
||||
self.scraper = cloudscraper.create_scraper(browser={'browser': 'firefox', 'platform': 'windows', 'mobile': False})
|
||||
except Exception as e:
|
||||
try: self.progress_signal.emit(f"❌ ERROR: Failed to initialize cloudscraper: {e}")
|
||||
except: pass
|
||||
self.scraper = None
|
||||
|
||||
# --- Control Methods (cancel, pause, resume - same as before) ---
|
||||
def cancel(self):
|
||||
self._is_cancelled_internal = True
|
||||
self.cancellation_event.set()
|
||||
try: self.progress_signal.emit(" Cancellation requested for Kemono Discord download.")
|
||||
except: pass
|
||||
|
||||
def pause(self):
|
||||
if not self.pause_event.is_set():
|
||||
self.pause_event.set()
|
||||
try: self.progress_signal.emit(" Pausing Kemono Discord download...")
|
||||
except: pass
|
||||
|
||||
def resume(self):
|
||||
if self.pause_event.is_set():
|
||||
self.pause_event.clear()
|
||||
try: self.progress_signal.emit(" Resuming Kemono Discord download...")
|
||||
except: pass
|
||||
|
||||
# --- Helper: Check Cancellation/Pause (same as before) ---
|
||||
def _check_events(self):
|
||||
if self._is_cancelled_internal or self.cancellation_event.is_set():
|
||||
if not self._is_cancelled_internal:
|
||||
self._is_cancelled_internal = True
|
||||
try: self.progress_signal.emit(" Cancellation detected by Kemono Discord thread check.")
|
||||
except: pass
|
||||
return True # Cancelled
|
||||
|
||||
was_paused = False
|
||||
while self.pause_event.is_set():
|
||||
if not was_paused:
|
||||
try: self.progress_signal.emit(" Kemono Discord operation paused...")
|
||||
except: pass
|
||||
was_paused = True
|
||||
if self.cancellation_event.is_set():
|
||||
self._is_cancelled_internal = True
|
||||
try: self.progress_signal.emit(" Cancellation detected while paused.")
|
||||
except: pass
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
# --- REVISED Helper: Download Single File with ONE Retry ---
|
||||
def _download_single_kemono_file(self, file_info):
|
||||
"""
|
||||
Downloads a single file, handles collisions after download,
|
||||
and automatically retries ONCE after 15s for specific network errors.
|
||||
|
||||
Returns:
|
||||
tuple: (bool_success, dict_error_details_or_None)
|
||||
"""
|
||||
# --- Constants for Retry Logic ---
|
||||
MAX_ATTEMPTS = 2 # 1 initial attempt + 1 retry
|
||||
RETRY_DELAY_SECONDS = 15
|
||||
|
||||
# --- Extract info ---
|
||||
channel_dir = file_info['channel_dir']
|
||||
original_filename = file_info['original_filename']
|
||||
file_url = file_info['file_url']
|
||||
channel_id = file_info['channel_id']
|
||||
post_title = file_info.get('post_title', f"Message in channel {channel_id}")
|
||||
original_post_id_for_log = file_info.get('message_id', 'N/A')
|
||||
base_kemono_domain = "kemono.cr"
|
||||
|
||||
if not self.scraper:
|
||||
try: self.progress_signal.emit(f" ❌ Cannot download '{original_filename}': Cloudscraper not initialized.")
|
||||
except: pass
|
||||
failure_details = { 'file_info': {'url': file_url, 'name': original_filename}, 'post_title': post_title, 'original_post_id_for_log': original_post_id_for_log, 'target_folder_path': channel_dir, 'error': 'Cloudscraper not initialized', 'service': 'discord', 'user_id': self.server_id }
|
||||
return False, failure_details
|
||||
|
||||
if self._check_events(): return False, None # Interrupted before start
|
||||
|
||||
# --- Determine filenames ---
|
||||
cleaned_original_filename = clean_filename(original_filename)
|
||||
intended_final_filename = cleaned_original_filename
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
temp_filename = f"{intended_final_filename}.{unique_suffix}.part"
|
||||
temp_filepath = os.path.join(channel_dir, temp_filename)
|
||||
|
||||
# --- Download Attempt Loop ---
|
||||
download_successful = False
|
||||
last_exception = None
|
||||
should_retry = False # Flag to indicate if the first attempt failed with a retryable error
|
||||
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
response = None
|
||||
try:
|
||||
# --- Pre-attempt checks ---
|
||||
if self._check_events(): raise InterruptedError("Cancelled/Paused before attempt")
|
||||
if attempt == 2 and should_retry: # Only delay *before* the retry
|
||||
try: self.progress_signal.emit(f" ⏳ Retrying '{original_filename}' (Attempt {attempt}/{MAX_ATTEMPTS}) after {RETRY_DELAY_SECONDS}s...")
|
||||
except: pass
|
||||
for _ in range(RETRY_DELAY_SECONDS):
|
||||
if self._check_events(): raise InterruptedError("Cancelled/Paused during retry delay")
|
||||
time.sleep(1)
|
||||
# If it's attempt 2 but should_retry is False, it means the first error was non-retryable, so skip
|
||||
elif attempt == 2 and not should_retry:
|
||||
break # Exit loop, failure already determined
|
||||
|
||||
# --- Log attempt ---
|
||||
log_prefix = f" ⬇️ Downloading:" if attempt == 1 else f" 🔄 Retrying:"
|
||||
try: self.progress_signal.emit(f"{log_prefix} '{original_filename}' (Attempt {attempt}/{MAX_ATTEMPTS})...")
|
||||
except: pass
|
||||
if attempt == 1:
|
||||
try: self.file_progress_signal.emit(original_filename, (0, 0))
|
||||
except: pass
|
||||
|
||||
# --- Perform Download ---
|
||||
headers = { 'User-Agent': 'Mozilla/5.0 ...', 'Referer': f'https://{base_kemono_domain}/discord/channel/{channel_id}'} # Shortened for brevity
|
||||
response = self.scraper.get(file_url, headers=headers, cookies=self.cookies_dict, stream=True, timeout=(15, 120))
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
last_progress_emit_time = time.time()
|
||||
|
||||
with open(temp_filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=1024*1024):
|
||||
if self._check_events(): raise InterruptedError("Cancelled/Paused during chunk writing")
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
current_time = time.time()
|
||||
if total_size > 0 and (current_time - last_progress_emit_time > 0.5 or downloaded_size == total_size):
|
||||
try: self.file_progress_signal.emit(original_filename, (downloaded_size, total_size))
|
||||
except: pass
|
||||
last_progress_emit_time = current_time
|
||||
elif total_size == 0 and (current_time - last_progress_emit_time > 0.5):
|
||||
try: self.file_progress_signal.emit(original_filename, (downloaded_size, 0))
|
||||
except: pass
|
||||
last_progress_emit_time = current_time
|
||||
response.close()
|
||||
|
||||
# --- Verification ---
|
||||
if self._check_events(): raise InterruptedError("Cancelled/Paused after download completion")
|
||||
|
||||
if total_size > 0 and downloaded_size != total_size:
|
||||
try: self.progress_signal.emit(f" ⚠️ Size mismatch on attempt {attempt} for '{original_filename}'. Expected {total_size}, got {downloaded_size}.")
|
||||
except: pass
|
||||
last_exception = IOError(f"Size mismatch: Expected {total_size}, got {downloaded_size}")
|
||||
if os.path.exists(temp_filepath):
|
||||
try: os.remove(temp_filepath)
|
||||
except OSError: pass
|
||||
should_retry = (attempt == 1) # Only retry if it was the first attempt
|
||||
continue # Try again if attempt 1, otherwise loop finishes
|
||||
else:
|
||||
download_successful = True
|
||||
break # Success!
|
||||
|
||||
# --- Error Handling within Loop ---
|
||||
except InterruptedError as e:
|
||||
last_exception = e
|
||||
should_retry = False # Don't retry if interrupted
|
||||
break
|
||||
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError, cloudscraper.exceptions.CloudflareException) as e:
|
||||
last_exception = e
|
||||
try: self.progress_signal.emit(f" ❌ Network/Cloudflare error on attempt {attempt} for '{original_filename}': {e}")
|
||||
except: pass
|
||||
should_retry = (attempt == 1) # Retry only if first attempt
|
||||
except requests.exceptions.RequestException as e:
|
||||
status_code = getattr(e.response, 'status_code', None)
|
||||
if status_code and 500 <= status_code <= 599: # Retry on 5xx
|
||||
last_exception = e
|
||||
try: self.progress_signal.emit(f" ❌ Server error ({status_code}) on attempt {attempt} for '{original_filename}'. Will retry...")
|
||||
except: pass
|
||||
should_retry = (attempt == 1) # Retry only if first attempt
|
||||
else: # Don't retry on 4xx or other request errors
|
||||
last_exception = e
|
||||
try: self.progress_signal.emit(f" ❌ Non-retryable HTTP error for '{original_filename}': {e}")
|
||||
except: pass
|
||||
should_retry = False
|
||||
break
|
||||
except OSError as e:
|
||||
last_exception = e
|
||||
try: self.progress_signal.emit(f" ❌ OS error during download attempt {attempt} for '{original_filename}': {e}")
|
||||
except: pass
|
||||
should_retry = False
|
||||
break
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
try: self.progress_signal.emit(f" ❌ Unexpected error on attempt {attempt} for '{original_filename}': {e}")
|
||||
except: pass
|
||||
should_retry = False
|
||||
break
|
||||
finally:
|
||||
if response:
|
||||
try: response.close()
|
||||
except Exception: pass
|
||||
# --- End Download Attempt Loop ---
|
||||
|
||||
try: self.file_progress_signal.emit(original_filename, None) # Clear progress
|
||||
except: pass
|
||||
|
||||
# --- Post-Download Processing ---
|
||||
if download_successful:
|
||||
# --- Rename Logic ---
|
||||
final_filename_to_use = intended_final_filename
|
||||
final_filepath_on_disk = os.path.join(channel_dir, final_filename_to_use)
|
||||
counter = 1
|
||||
base_name, extension = os.path.splitext(intended_final_filename)
|
||||
while os.path.exists(final_filepath_on_disk):
|
||||
final_filename_to_use = f"{base_name} ({counter}){extension}"
|
||||
final_filepath_on_disk = os.path.join(channel_dir, final_filename_to_use)
|
||||
counter += 1
|
||||
if final_filename_to_use != intended_final_filename:
|
||||
try: self.progress_signal.emit(f" -> Name conflict for '{intended_final_filename}'. Renaming to '{final_filename_to_use}'.")
|
||||
except: pass
|
||||
try:
|
||||
os.rename(temp_filepath, final_filepath_on_disk)
|
||||
try: self.progress_signal.emit(f" ✅ Saved: '{final_filename_to_use}'")
|
||||
except: pass
|
||||
return True, None # SUCCESS
|
||||
except OSError as e:
|
||||
try: self.progress_signal.emit(f" ❌ OS error renaming temp file to '{final_filename_to_use}': {e}")
|
||||
except: pass
|
||||
if os.path.exists(temp_filepath):
|
||||
try: os.remove(temp_filepath)
|
||||
except OSError: pass
|
||||
# ---> RETURN FAILURE TUPLE (Rename Failed) <---
|
||||
failure_details = { 'file_info': {'url': file_url, 'name': original_filename}, 'post_title': post_title, 'original_post_id_for_log': original_post_id_for_log, 'target_folder_path': channel_dir, 'intended_filename': intended_final_filename, 'error': f"Rename failed: {e}", 'service': 'discord', 'user_id': self.server_id }
|
||||
return False, failure_details
|
||||
else:
|
||||
# Download failed or was interrupted
|
||||
if not isinstance(last_exception, InterruptedError):
|
||||
try: self.progress_signal.emit(f" ❌ FAILED to download '{original_filename}' after {MAX_ATTEMPTS} attempts. Last error: {last_exception}")
|
||||
except: pass
|
||||
if os.path.exists(temp_filepath):
|
||||
try: os.remove(temp_filepath)
|
||||
except OSError as e_rem:
|
||||
try: self.progress_signal.emit(f" (Failed to remove temp file '{temp_filename}': {e_rem})")
|
||||
except: pass
|
||||
# ---> RETURN FAILURE TUPLE (Download Failed/Interrupted) <---
|
||||
# Only generate details if it wasn't interrupted by user
|
||||
failure_details = None
|
||||
if not isinstance(last_exception, InterruptedError):
|
||||
failure_details = {
|
||||
'file_info': {'url': file_url, 'name': original_filename},
|
||||
'post_title': post_title, 'original_post_id_for_log': original_post_id_for_log,
|
||||
'target_folder_path': channel_dir, 'intended_filename': intended_final_filename,
|
||||
'error': f"Failed after {MAX_ATTEMPTS} attempts: {last_exception}",
|
||||
'service': 'discord', 'user_id': self.server_id,
|
||||
'forced_filename_override': intended_final_filename,
|
||||
'file_index_in_post': file_info.get('file_index', 0),
|
||||
'num_files_in_this_post': file_info.get('num_files', 1)
|
||||
}
|
||||
return False, failure_details # Return None details if interrupted
|
||||
|
||||
# --- Main Thread Execution ---
|
||||
def run(self):
|
||||
"""Main execution logic: Fetches channels/messages and dispatches file downloads."""
|
||||
self.download_count = 0
|
||||
self.skip_count = 0
|
||||
self._is_cancelled_internal = False
|
||||
self.permanently_failed_details = [] # Reset failed list
|
||||
|
||||
if not self.scraper:
|
||||
try: self.progress_signal.emit("❌ Aborting Kemono Discord download: Cloudscraper failed to initialize.")
|
||||
except: pass
|
||||
self.finished_signal.emit(0, 0, False, [])
|
||||
return
|
||||
|
||||
try:
|
||||
# --- Log Start ---
|
||||
try:
|
||||
self.progress_signal.emit("=" * 40)
|
||||
self.progress_signal.emit(f"🚀 Starting Kemono Discord download for server: {self.server_id}")
|
||||
self.progress_signal.emit(f" Using {self.num_file_threads} thread(s) for file downloads.")
|
||||
except: pass
|
||||
|
||||
# --- Channel Fetching (same as before) ---
|
||||
channels_to_process = []
|
||||
# ... (logic to populate channels_to_process using fetch_server_channels or target_channel_id) ...
|
||||
if self.target_channel_id:
|
||||
channels_to_process.append({'id': self.target_channel_id, 'name': self.target_channel_id})
|
||||
try: self.progress_signal.emit(f" Targeting specific channel: {self.target_channel_id}")
|
||||
except: pass
|
||||
else:
|
||||
try: self.progress_label_signal.emit("Fetching server channels via Kemono API...")
|
||||
except: pass
|
||||
channels_data = fetch_server_channels(self.server_id, logger=self.progress_signal.emit, cookies_dict=self.cookies_dict)
|
||||
if self._check_events(): return
|
||||
if channels_data is not None:
|
||||
channels_to_process = channels_data
|
||||
try: self.progress_signal.emit(f" Found {len(channels_to_process)} channels.")
|
||||
except: pass
|
||||
else:
|
||||
try: self.progress_signal.emit(f" ❌ Failed to fetch channels for server {self.server_id} via Kemono API.")
|
||||
except: pass
|
||||
return
|
||||
|
||||
# --- Process Each Channel ---
|
||||
for channel in channels_to_process:
|
||||
if self._check_events(): break
|
||||
|
||||
channel_id = channel['id']
|
||||
channel_name = clean_filename(channel.get('name', channel_id))
|
||||
channel_dir = os.path.join(self.output_dir, channel_name)
|
||||
try:
|
||||
os.makedirs(channel_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
try: self.progress_signal.emit(f" ❌ Failed to create directory for channel '{channel_name}': {e}. Skipping channel.")
|
||||
except: pass
|
||||
continue
|
||||
|
||||
try:
|
||||
self.progress_signal.emit(f"\n--- Processing Channel: #{channel_name} ({channel_id}) ---")
|
||||
self.progress_label_signal.emit(f"Fetching messages for #{channel_name}...")
|
||||
except: pass
|
||||
|
||||
# --- Collect File Download Tasks ---
|
||||
file_tasks = []
|
||||
message_generator = fetch_channel_messages(
|
||||
channel_id, logger=self.progress_signal.emit,
|
||||
cancellation_event=self.cancellation_event, pause_event=self.pause_event,
|
||||
cookies_dict=self.cookies_dict
|
||||
)
|
||||
|
||||
try:
|
||||
message_index = 0
|
||||
for message_batch in message_generator:
|
||||
if self._check_events(): break
|
||||
for message in message_batch:
|
||||
message_id = message.get('id', f'msg_{message_index}')
|
||||
post_title_context = (message.get('content') or f"Message {message_id}")[:50] + "..."
|
||||
attachments = message.get('attachments', [])
|
||||
file_index_in_message = 0
|
||||
num_files_in_message = len(attachments)
|
||||
|
||||
for attachment in attachments:
|
||||
if self._check_events(): raise InterruptedError
|
||||
file_path = attachment.get('path')
|
||||
original_filename = attachment.get('name')
|
||||
if file_path and original_filename:
|
||||
base_kemono_domain = "kemono.cr"
|
||||
if not file_path.startswith('/'): file_path = '/' + file_path
|
||||
file_url = f"https://{base_kemono_domain}/data{file_path}"
|
||||
file_tasks.append({
|
||||
'channel_dir': channel_dir, 'original_filename': original_filename,
|
||||
'file_url': file_url, 'channel_id': channel_id,
|
||||
'message_id': message_id, 'post_title': post_title_context,
|
||||
'file_index': file_index_in_message, 'num_files': num_files_in_message
|
||||
})
|
||||
file_index_in_message += 1
|
||||
message_index += 1
|
||||
if self._check_events(): raise InterruptedError
|
||||
if self._check_events(): raise InterruptedError
|
||||
except InterruptedError:
|
||||
try: self.progress_signal.emit(" Interrupted while collecting file tasks.")
|
||||
except: pass
|
||||
break # Exit channel processing
|
||||
except Exception as e_msg:
|
||||
try: self.progress_signal.emit(f" ❌ Error fetching messages for channel {channel_name}: {e_msg}")
|
||||
except: pass
|
||||
continue # Continue to next channel
|
||||
|
||||
if self._check_events(): break
|
||||
|
||||
if not file_tasks:
|
||||
try: self.progress_signal.emit(" No downloadable file attachments found in this channel's messages.")
|
||||
except: pass
|
||||
continue
|
||||
|
||||
try:
|
||||
self.progress_signal.emit(f" Found {len(file_tasks)} potential file attachments. Starting downloads...")
|
||||
self.progress_label_signal.emit(f"Downloading {len(file_tasks)} files for #{channel_name}...")
|
||||
except: pass
|
||||
|
||||
# --- Execute Downloads Concurrently ---
|
||||
files_processed_in_channel = 0
|
||||
with ThreadPoolExecutor(max_workers=self.num_file_threads, thread_name_prefix=f"KDC_{channel_id[:4]}_") as executor:
|
||||
futures = {executor.submit(self._download_single_kemono_file, task): task for task in file_tasks}
|
||||
try:
|
||||
for future in as_completed(futures):
|
||||
files_processed_in_channel += 1
|
||||
task_info = futures[future]
|
||||
try:
|
||||
success, details = future.result() # Unpack result
|
||||
with self.count_lock:
|
||||
if success:
|
||||
self.download_count += 1
|
||||
else:
|
||||
self.skip_count += 1
|
||||
if details: # Append details if the download permanently failed
|
||||
self.permanently_failed_details.append(details)
|
||||
except Exception as e_future:
|
||||
filename = task_info.get('original_filename', 'unknown file')
|
||||
try: self.progress_signal.emit(f" ❌ System error processing download future for '{filename}': {e_future}")
|
||||
except: pass
|
||||
with self.count_lock:
|
||||
self.skip_count += 1
|
||||
# Append details on system failure
|
||||
failure_details = { 'file_info': {'url': task_info.get('file_url'), 'name': filename}, 'post_title': task_info.get('post_title', 'N/A'), 'original_post_id_for_log': task_info.get('message_id', 'N/A'), 'target_folder_path': task_info.get('channel_dir'), 'error': f"Future execution error: {e_future}", 'service': 'discord', 'user_id': self.server_id, 'forced_filename_override': clean_filename(filename), 'file_index_in_post': task_info.get('file_index', 0), 'num_files_in_this_post': task_info.get('num_files', 1) }
|
||||
self.permanently_failed_details.append(failure_details)
|
||||
|
||||
try: self.progress_label_signal.emit(f"#{channel_name}: {files_processed_in_channel}/{len(file_tasks)} files processed")
|
||||
except: pass
|
||||
|
||||
if self._check_events():
|
||||
try: self.progress_signal.emit(" Cancelling remaining file downloads for this channel...")
|
||||
except: pass
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
break # Exit as_completed loop
|
||||
except InterruptedError:
|
||||
try: self.progress_signal.emit(" Download processing loop interrupted.")
|
||||
except: pass
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
if self._check_events(): break # Check between channels
|
||||
|
||||
# --- End Channel Loop ---
|
||||
|
||||
except Exception as e:
|
||||
# Catch unexpected errors in the main run logic
|
||||
try:
|
||||
self.progress_signal.emit(f"❌ Unexpected critical error in Kemono Discord thread run loop: {e}")
|
||||
import traceback
|
||||
self.progress_signal.emit(traceback.format_exc())
|
||||
except: pass # Avoid errors if signals fail at the very end
|
||||
finally:
|
||||
# --- Final Cleanup and Signal ---
|
||||
try:
|
||||
try: self.progress_signal.emit("=" * 40)
|
||||
except: pass
|
||||
cancelled = self._is_cancelled_internal or self.cancellation_event.is_set()
|
||||
|
||||
# --- EMIT FAILED FILES SIGNAL ---
|
||||
if self.permanently_failed_details:
|
||||
try:
|
||||
self.progress_signal.emit(f" Reporting {len(self.permanently_failed_details)} permanently failed files...")
|
||||
self.permanent_file_failed_signal.emit(list(self.permanently_failed_details)) # Emit a copy
|
||||
except Exception as e_emit_fail:
|
||||
print(f"ERROR emitting permanent_file_failed_signal: {e_emit_fail}")
|
||||
|
||||
# Log final status
|
||||
try:
|
||||
if cancelled and not self._is_cancelled_internal:
|
||||
self.progress_signal.emit(" Kemono Discord download cancelled externally.")
|
||||
elif self._is_cancelled_internal:
|
||||
self.progress_signal.emit(" Kemono Discord download finished due to cancellation.")
|
||||
else:
|
||||
self.progress_signal.emit("✅ Kemono Discord download process finished.")
|
||||
except: pass
|
||||
|
||||
# Clear file progress
|
||||
try: self.file_progress_signal.emit("", None)
|
||||
except: pass
|
||||
|
||||
# Get final counts safely
|
||||
with self.count_lock:
|
||||
final_download_count = self.download_count
|
||||
final_skip_count = self.skip_count
|
||||
|
||||
# Emit finished signal
|
||||
self.finished_signal.emit(final_download_count, final_skip_count, cancelled, [])
|
||||
except Exception as e_final:
|
||||
# Log final signal emission error if possible
|
||||
print(f"ERROR in KemonoDiscordDownloadThread finally block: {e_final}")
|
||||
@@ -134,7 +134,9 @@ class SimpCityDownloadThread(QThread):
|
||||
with self.counter_lock: self.total_skip_count += 1
|
||||
return
|
||||
self.progress_signal.emit(f" -> Downloading (Image): '{filename}'...")
|
||||
response = session.get(job['url'], stream=True, timeout=90, headers={'Referer': self.start_url})
|
||||
# --- START MODIFICATION ---
|
||||
response = session.get(job['url'], stream=True, timeout=180, headers={'Referer': self.start_url})
|
||||
# --- END MODIFICATION ---
|
||||
response.raise_for_status()
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
@@ -227,7 +229,9 @@ class SimpCityDownloadThread(QThread):
|
||||
else:
|
||||
self.progress_signal.emit(f" -> Downloading: '{filename}'...")
|
||||
headers = file_data.get('headers', {'Referer': source_url})
|
||||
response = session.get(file_data.get('url'), stream=True, timeout=90, headers=headers)
|
||||
# --- START MODIFICATION ---
|
||||
response = session.get(file_data.get('url'), stream=True, timeout=180, headers=headers)
|
||||
# --- END MODIFICATION ---
|
||||
response.raise_for_status()
|
||||
with open(filepath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
@@ -298,16 +302,30 @@ class SimpCityDownloadThread(QThread):
|
||||
try:
|
||||
page_title, jobs_on_page, final_url = fetch_single_simpcity_page(page_url, self._log_interceptor, cookies=self.cookies)
|
||||
|
||||
# --- START: MODIFIED REDIRECT LOGIC ---
|
||||
if final_url != page_url:
|
||||
self.progress_signal.emit(f" -> Redirect detected from {page_url} to {final_url}")
|
||||
try:
|
||||
req_page_match = re.search(r'/page-(\d+)', page_url)
|
||||
final_page_match = re.search(r'/page-(\d+)', final_url)
|
||||
if req_page_match and final_page_match and int(final_page_match.group(1)) < int(req_page_match.group(1)):
|
||||
self.progress_signal.emit(" -> Redirected to an earlier page. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
if req_page_match:
|
||||
req_page_num = int(req_page_match.group(1))
|
||||
|
||||
# Scenario 1: Redirect to an earlier page (e.g., page-11 -> page-10)
|
||||
if final_page_match and int(final_page_match.group(1)) < req_page_num:
|
||||
self.progress_signal.emit(f" -> Redirected to an earlier page ({final_page_match.group(0)}). Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
# Scenario 2: Redirect to base URL (e.g., page-11 -> /)
|
||||
# We check req_page_num > 1 because page-1 often redirects to base URL, which is normal.
|
||||
elif not final_page_match and req_page_num > 1:
|
||||
self.progress_signal.emit(f" -> Redirected to base thread URL. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
pass # Ignore parsing errors
|
||||
# --- END: MODIFIED REDIRECT LOGIC ---
|
||||
|
||||
if end_of_thread:
|
||||
page_fetch_successful = True; break
|
||||
@@ -316,25 +334,40 @@ class SimpCityDownloadThread(QThread):
|
||||
self.progress_signal.emit(f" -> Page {page_counter} is invalid or has no title. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
elif not jobs_on_page:
|
||||
self.progress_signal.emit(f" -> Page {page_counter} has no content. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
else:
|
||||
new_jobs = [job for job in jobs_on_page if job.get('url') not in self.processed_job_urls]
|
||||
if not new_jobs and page_counter > 1:
|
||||
self.progress_signal.emit(f" -> Page {page_counter} contains no new content. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
else:
|
||||
enriched_jobs = self._get_enriched_jobs(new_jobs)
|
||||
for job in enriched_jobs:
|
||||
self.processed_job_urls.add(job.get('url'))
|
||||
if job['type'] == 'image': self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
if not enriched_jobs and not new_jobs:
|
||||
# This can happen if all new_jobs were e.g. pixeldrain and it's disabled
|
||||
self.progress_signal.emit(f" -> Page {page_counter} content was filtered out. Reached end of thread.")
|
||||
end_of_thread = True
|
||||
else:
|
||||
for job in enriched_jobs:
|
||||
self.processed_job_urls.add(job.get('url'))
|
||||
if job['type'] == 'image': self.image_queue.put(job)
|
||||
else: self.service_queue.put(job)
|
||||
page_fetch_successful = True; break
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code in [403, 404]: end_of_thread = True; break
|
||||
elif e.response.status_code == 429: time.sleep(5 * (retries + 2)); retries += 1
|
||||
else: end_of_thread = True; break
|
||||
if e.response.status_code in [403, 404]:
|
||||
self.progress_signal.emit(f" -> Page {page_counter} returned {e.response.status_code}. Reached end of thread.")
|
||||
end_of_thread = True; break
|
||||
elif e.response.status_code == 429:
|
||||
self.progress_signal.emit(f" -> Rate limited (429). Waiting...")
|
||||
time.sleep(5 * (retries + 2)); retries += 1
|
||||
else:
|
||||
self.progress_signal.emit(f" -> HTTP Error {e.response.status_code} on page {page_counter}. Stopping crawl.")
|
||||
end_of_thread = True; break
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f" Stopping crawl due to error on page {page_counter}: {e}"); end_of_thread = True; break
|
||||
if not page_fetch_successful and not end_of_thread: end_of_thread = True
|
||||
if not page_fetch_successful and not end_of_thread:
|
||||
self.progress_signal.emit(f" -> Failed to fetch page {page_counter} after {MAX_RETRIES} attempts. Stopping crawl.")
|
||||
end_of_thread = True
|
||||
if not end_of_thread: page_counter += 1
|
||||
except Exception as e:
|
||||
self.progress_signal.emit(f"❌ A critical error occurred during the main fetch phase: {e}")
|
||||
|
||||
@@ -22,6 +22,8 @@ from ..main_window import get_app_icon_object
|
||||
from ...core.api_client import download_from_api
|
||||
from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ...utils.resolution import get_dark_theme
|
||||
# --- IMPORT THE NEW DIALOG ---
|
||||
from .UpdateCheckDialog import UpdateCheckDialog
|
||||
|
||||
|
||||
class PostsFetcherThread (QThread ):
|
||||
@@ -138,7 +140,7 @@ class EmptyPopupDialog (QDialog ):
|
||||
SCOPE_CREATORS ="Creators"
|
||||
|
||||
|
||||
def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
|
||||
def __init__ (self ,user_data_path ,parent_app_ref ,parent =None ):
|
||||
super ().__init__ (parent )
|
||||
self.parent_app = parent_app_ref
|
||||
|
||||
@@ -146,13 +148,18 @@ class EmptyPopupDialog (QDialog ):
|
||||
|
||||
self.setMinimumSize(int(400 * scale_factor), int(300 * scale_factor))
|
||||
self.current_scope_mode = self.SCOPE_CREATORS
|
||||
self .app_base_dir =app_base_dir
|
||||
self.user_data_path = user_data_path
|
||||
|
||||
app_icon =get_app_icon_object ()
|
||||
if app_icon and not app_icon .isNull ():
|
||||
self .setWindowIcon (app_icon )
|
||||
|
||||
# --- MODIFIED: Store a list of profiles now ---
|
||||
self.update_profiles_list = None
|
||||
# --- DEPRECATED (kept for compatibility if needed, but new logic won't use them) ---
|
||||
self.update_profile_data = None
|
||||
self.update_creator_name = None
|
||||
|
||||
self .selected_creators_for_queue =[]
|
||||
self .globally_selected_creators ={}
|
||||
self .fetched_posts_data ={}
|
||||
@@ -321,29 +328,34 @@ class EmptyPopupDialog (QDialog ):
|
||||
pass
|
||||
|
||||
def _handle_update_check(self):
|
||||
"""Opens a dialog to select a creator profile and loads it for an update session."""
|
||||
appdata_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
"""
|
||||
--- MODIFIED FUNCTION ---
|
||||
Opens the new UpdateCheckDialog instead of a QFileDialog.
|
||||
If a profile is selected, it sets the dialog's result properties
|
||||
and accepts the dialog, just like the old file dialog logic did.
|
||||
"""
|
||||
# --- NEW BEHAVIOR ---
|
||||
# Pass the app_base_dir and a reference to the main app (for translations/theme)
|
||||
dialog = UpdateCheckDialog(self.user_data_path, self.parent_app, self)
|
||||
|
||||
if not os.path.isdir(profiles_dir):
|
||||
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
|
||||
return
|
||||
|
||||
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if 'creator_url' not in data or 'processed_post_ids' not in data:
|
||||
raise ValueError("Invalid profile format.")
|
||||
|
||||
self.update_profile_data = data
|
||||
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
|
||||
self.accept() # Close the dialog and signal success
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
# --- MODIFIED: Get a list of profiles now ---
|
||||
selected_profiles = dialog.get_selected_profiles()
|
||||
if selected_profiles:
|
||||
try:
|
||||
# --- MODIFIED: Store the list ---
|
||||
self.update_profiles_list = selected_profiles
|
||||
|
||||
# --- Set deprecated single-profile fields for backward compatibility (optional) ---
|
||||
# --- This helps if other parts of the main window still expect one profile ---
|
||||
self.update_profile_data = selected_profiles[0]['data']
|
||||
self.update_creator_name = selected_profiles[0]['name']
|
||||
|
||||
self.accept() # Close EmptyPopupDialog and signal success to main_window
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error Loading Profile",
|
||||
f"Could not process the selected profile data:\n\n{e}")
|
||||
# --- END OF NEW BEHAVIOR ---
|
||||
|
||||
def _handle_fetch_posts_click (self ):
|
||||
selected_creators =list (self .globally_selected_creators .values ())
|
||||
@@ -981,9 +993,14 @@ class EmptyPopupDialog (QDialog ):
|
||||
def _handle_posts_close_view (self ):
|
||||
self .right_pane_widget .hide ()
|
||||
self .main_splitter .setSizes ([self .width (),0 ])
|
||||
self .posts_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
|
||||
|
||||
# --- MODIFIED: Added check before disconnect ---
|
||||
if hasattr (self ,'_handle_post_item_check_changed'):
|
||||
self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
|
||||
try:
|
||||
self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
|
||||
except TypeError:
|
||||
pass # Already disconnected
|
||||
|
||||
self .posts_search_input .setVisible (False )
|
||||
self .posts_search_input .clear ()
|
||||
self .globally_selected_post_ids .clear ()
|
||||
|
||||
@@ -7,7 +7,8 @@ import sys
|
||||
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox, QLineEdit,
|
||||
QTabWidget, QWidget, QFileDialog # Added QFileDialog
|
||||
)
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
@@ -111,7 +112,7 @@ class CountdownMessageBox(QDialog):
|
||||
class FutureSettingsDialog(QDialog):
|
||||
"""
|
||||
A dialog for managing application-wide settings like theme, language,
|
||||
and display options, with an organized layout.
|
||||
and display options, using a tabbed layout.
|
||||
"""
|
||||
def __init__(self, parent_app_ref, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -124,8 +125,9 @@ class FutureSettingsDialog(QDialog):
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
|
||||
scale_factor = screen_height / 800.0
|
||||
base_min_w, base_min_h = 420, 520 # Increased height for new options
|
||||
# Use a more balanced aspect ratio
|
||||
scale_factor = screen_height / 1000.0
|
||||
base_min_w, base_min_h = 480, 420 # Wider, less tall
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
@@ -137,67 +139,105 @@ class FutureSettingsDialog(QDialog):
|
||||
def _init_ui(self):
|
||||
"""Initializes all UI components and layouts for the dialog."""
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# --- Create Tab Widget ---
|
||||
self.tab_widget = QTabWidget()
|
||||
main_layout.addWidget(self.tab_widget)
|
||||
|
||||
self.interface_group_box = QGroupBox()
|
||||
interface_layout = QGridLayout(self.interface_group_box)
|
||||
# --- Create Tabs ---
|
||||
self.display_tab = QWidget()
|
||||
self.downloads_tab = QWidget()
|
||||
self.updates_tab = QWidget()
|
||||
|
||||
# Add tabs to the widget
|
||||
self.tab_widget.addTab(self.display_tab, "Display")
|
||||
self.tab_widget.addTab(self.downloads_tab, "Downloads")
|
||||
self.tab_widget.addTab(self.updates_tab, "Updates")
|
||||
|
||||
# --- Populate Display Tab ---
|
||||
display_tab_layout = QVBoxLayout(self.display_tab)
|
||||
self.display_group_box = QGroupBox()
|
||||
display_layout = QGridLayout(self.display_group_box)
|
||||
|
||||
self.theme_label = QLabel()
|
||||
self.theme_toggle_button = QPushButton()
|
||||
self.theme_toggle_button.clicked.connect(self._toggle_theme)
|
||||
interface_layout.addWidget(self.theme_label, 0, 0)
|
||||
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
display_layout.addWidget(self.theme_label, 0, 0)
|
||||
display_layout.addWidget(self.theme_toggle_button, 0, 1)
|
||||
|
||||
self.ui_scale_label = QLabel()
|
||||
self.ui_scale_combo_box = QComboBox()
|
||||
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
interface_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
display_layout.addWidget(self.ui_scale_label, 1, 0)
|
||||
display_layout.addWidget(self.ui_scale_combo_box, 1, 1)
|
||||
|
||||
self.language_label = QLabel()
|
||||
self.language_combo_box = QComboBox()
|
||||
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
|
||||
interface_layout.addWidget(self.language_label, 2, 0)
|
||||
interface_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
display_layout.addWidget(self.language_label, 2, 0)
|
||||
display_layout.addWidget(self.language_combo_box, 2, 1)
|
||||
|
||||
main_layout.addWidget(self.interface_group_box)
|
||||
|
||||
self.download_window_group_box = QGroupBox()
|
||||
download_window_layout = QGridLayout(self.download_window_group_box)
|
||||
|
||||
self.window_size_label = QLabel()
|
||||
self.resolution_combo_box = QComboBox()
|
||||
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
|
||||
download_window_layout.addWidget(self.window_size_label, 0, 0)
|
||||
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
|
||||
display_layout.addWidget(self.window_size_label, 3, 0)
|
||||
display_layout.addWidget(self.resolution_combo_box, 3, 1)
|
||||
|
||||
display_tab_layout.addWidget(self.display_group_box)
|
||||
display_tab_layout.addStretch(1) # Push content to the top
|
||||
|
||||
# --- Populate Downloads Tab ---
|
||||
downloads_tab_layout = QVBoxLayout(self.downloads_tab)
|
||||
self.download_settings_group_box = QGroupBox()
|
||||
download_settings_layout = QGridLayout(self.download_settings_group_box)
|
||||
|
||||
self.default_path_label = QLabel()
|
||||
self.save_path_button = QPushButton()
|
||||
self.save_path_button.clicked.connect(self._save_settings)
|
||||
download_window_layout.addWidget(self.default_path_label, 1, 0)
|
||||
download_window_layout.addWidget(self.save_path_button, 1, 1)
|
||||
|
||||
self.date_prefix_format_label = QLabel()
|
||||
self.date_prefix_format_input = QLineEdit()
|
||||
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
|
||||
download_window_layout.addWidget(self.date_prefix_format_label, 2, 0)
|
||||
download_window_layout.addWidget(self.date_prefix_format_input, 2, 1)
|
||||
download_settings_layout.addWidget(self.default_path_label, 0, 0)
|
||||
download_settings_layout.addWidget(self.save_path_button, 0, 1)
|
||||
|
||||
self.post_download_action_label = QLabel()
|
||||
self.post_download_action_combo = QComboBox()
|
||||
self.post_download_action_combo.currentIndexChanged.connect(self._post_download_action_changed)
|
||||
download_window_layout.addWidget(self.post_download_action_label, 3, 0)
|
||||
download_window_layout.addWidget(self.post_download_action_combo, 3, 1)
|
||||
download_settings_layout.addWidget(self.post_download_action_label, 1, 0)
|
||||
download_settings_layout.addWidget(self.post_download_action_combo, 1, 1)
|
||||
|
||||
self.date_prefix_format_label = QLabel()
|
||||
self.date_prefix_format_input = QLineEdit()
|
||||
self.date_prefix_format_input.textChanged.connect(self._date_prefix_format_changed)
|
||||
download_settings_layout.addWidget(self.date_prefix_format_label, 2, 0)
|
||||
download_settings_layout.addWidget(self.date_prefix_format_input, 2, 1)
|
||||
|
||||
self.save_creator_json_checkbox = QCheckBox()
|
||||
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
|
||||
download_window_layout.addWidget(self.save_creator_json_checkbox, 4, 0, 1, 2)
|
||||
download_settings_layout.addWidget(self.save_creator_json_checkbox, 3, 0, 1, 2)
|
||||
|
||||
self.fetch_first_checkbox = QCheckBox()
|
||||
self.fetch_first_checkbox.stateChanged.connect(self._fetch_first_setting_changed)
|
||||
download_window_layout.addWidget(self.fetch_first_checkbox, 5, 0, 1, 2)
|
||||
download_settings_layout.addWidget(self.fetch_first_checkbox, 4, 0, 1, 2)
|
||||
|
||||
main_layout.addWidget(self.download_window_group_box)
|
||||
# --- START: Add new Load/Save buttons ---
|
||||
settings_file_layout = QHBoxLayout()
|
||||
self.load_settings_button = QPushButton()
|
||||
self.save_settings_button = QPushButton()
|
||||
settings_file_layout.addWidget(self.load_settings_button)
|
||||
settings_file_layout.addWidget(self.save_settings_button)
|
||||
settings_file_layout.addStretch(1)
|
||||
|
||||
# Add this new layout to the grid
|
||||
download_settings_layout.addLayout(settings_file_layout, 5, 0, 1, 2) # Row 5, span 2 cols
|
||||
|
||||
# Connect signals
|
||||
self.load_settings_button.clicked.connect(self._handle_load_settings)
|
||||
self.save_settings_button.clicked.connect(self._handle_save_settings)
|
||||
# --- END: Add new Load/Save buttons ---
|
||||
|
||||
downloads_tab_layout.addWidget(self.download_settings_group_box)
|
||||
downloads_tab_layout.addStretch(1) # Push content to the top
|
||||
|
||||
# --- Populate Updates Tab ---
|
||||
updates_tab_layout = QVBoxLayout(self.updates_tab)
|
||||
self.update_group_box = QGroupBox()
|
||||
update_layout = QGridLayout(self.update_group_box)
|
||||
self.version_label = QLabel()
|
||||
@@ -207,29 +247,39 @@ class FutureSettingsDialog(QDialog):
|
||||
update_layout.addWidget(self.version_label, 0, 0)
|
||||
update_layout.addWidget(self.update_status_label, 0, 1)
|
||||
update_layout.addWidget(self.check_update_button, 1, 0, 1, 2)
|
||||
main_layout.addWidget(self.update_group_box)
|
||||
|
||||
main_layout.addStretch(1)
|
||||
|
||||
updates_tab_layout.addWidget(self.update_group_box)
|
||||
updates_tab_layout.addStretch(1) # Push content to the top
|
||||
|
||||
# --- OK Button (outside tabs) ---
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch(1)
|
||||
self.ok_button = QPushButton()
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
|
||||
def _retranslate_ui(self):
|
||||
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
|
||||
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
|
||||
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
|
||||
|
||||
# --- Tab Titles ---
|
||||
self.tab_widget.setTabText(0, self._tr("settings_tab_display", "Display"))
|
||||
self.tab_widget.setTabText(1, self._tr("settings_tab_downloads", "Downloads"))
|
||||
self.tab_widget.setTabText(2, self._tr("settings_tab_updates", "Updates"))
|
||||
|
||||
# --- Display Tab ---
|
||||
self.display_group_box.setTitle(self._tr("display_settings_group_title", "Display Settings"))
|
||||
self.theme_label.setText(self._tr("theme_label", "Theme:"))
|
||||
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
|
||||
self.language_label.setText(self._tr("language_label", "Language:"))
|
||||
|
||||
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
|
||||
# --- Downloads Tab ---
|
||||
self.download_settings_group_box.setTitle(self._tr("download_settings_group_title", "Download Settings"))
|
||||
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
|
||||
self.date_prefix_format_label.setText(self._tr("date_prefix_format_label", "Post Subfolder Format:"))
|
||||
# Update placeholder to include {post}
|
||||
self.date_prefix_format_input.setPlaceholderText(self._tr("date_prefix_format_placeholder", "e.g., YYYY-MM-DD {post} {postid}"))
|
||||
# Add the tooltip to explain usage
|
||||
self.date_prefix_format_input.setToolTip(self._tr(
|
||||
"date_prefix_format_tooltip",
|
||||
"Create a custom folder name using placeholders:\n"
|
||||
@@ -238,25 +288,32 @@ class FutureSettingsDialog(QDialog):
|
||||
"• {postid}: for the post's unique ID\n\n"
|
||||
"Example: {post} [{postid}] [YYYY-MM-DD]"
|
||||
))
|
||||
|
||||
|
||||
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||
|
||||
self.post_download_action_label.setText(self._tr("post_download_action_label", "Action After Download:"))
|
||||
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
||||
self.fetch_first_checkbox.setText(self._tr("fetch_first_label", "Fetch First (Download after all pages are found)"))
|
||||
self.fetch_first_checkbox.setToolTip(self._tr("fetch_first_tooltip", "If checked, the downloader will find all posts from a creator first before starting any downloads.\nThis can be slower to start but provides a more accurate progress bar."))
|
||||
self._update_theme_toggle_button_text()
|
||||
self.save_path_button.setText(self._tr("settings_save_all_button", "Save Path + Cookie + Token"))
|
||||
self.save_path_button.setToolTip(self._tr("settings_save_all_tooltip", "Save the current 'Download Location', Cookie, and Discord Token settings for future sessions."))
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
|
||||
# --- START: Add new button text ---
|
||||
self.load_settings_button.setText(self._tr("load_settings_button", "Load Settings..."))
|
||||
self.load_settings_button.setToolTip(self._tr("load_settings_tooltip", "Load all download settings from a .json file."))
|
||||
self.save_settings_button.setText(self._tr("save_settings_button", "Save Settings..."))
|
||||
self.save_settings_button.setToolTip(self._tr("save_settings_tooltip", "Save all current download settings to a .json file."))
|
||||
# --- END: Add new button text ---
|
||||
|
||||
# --- Updates Tab ---
|
||||
self.update_group_box.setTitle(self._tr("update_group_title", "Application Updates"))
|
||||
current_version = self.parent_app.windowTitle().split(' v')[-1]
|
||||
self.version_label.setText(self._tr("current_version_label", f"Current Version: v{current_version}"))
|
||||
self.update_status_label.setText(self._tr("update_status_ready", "Ready to check."))
|
||||
self.check_update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
|
||||
|
||||
# --- General ---
|
||||
self._update_theme_toggle_button_text()
|
||||
self.ok_button.setText(self._tr("ok_button", "OK"))
|
||||
|
||||
# --- Load Data ---
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
self._populate_post_download_action_combo()
|
||||
@@ -331,7 +388,38 @@ class FutureSettingsDialog(QDialog):
|
||||
def _apply_theme(self):
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
base_stylesheet = get_dark_theme(scale)
|
||||
|
||||
# --- START: Tab Styling Fix ---
|
||||
tab_stylesheet = """
|
||||
QTabWidget::pane {
|
||||
border-top: 1px solid #444;
|
||||
margin-top: -1px; /* Overlap with tab bar */
|
||||
background-color: #2D2D2D;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background-color: #3D3D3D;
|
||||
color: #BBBBBB;
|
||||
border: 1px solid #444;
|
||||
border-bottom: none; /* No bottom border for tabs */
|
||||
padding: 6px 12px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #2D2D2D; /* Same as pane background */
|
||||
color: #EEEEEE;
|
||||
border-bottom: 1px solid #2D2D2D; /* Hides the pane top border */
|
||||
margin-bottom: -1px; /* Pulls tab down to cover pane border */
|
||||
}
|
||||
QTabBar::tab:!selected:hover {
|
||||
background-color: #4A4A4A;
|
||||
}
|
||||
"""
|
||||
# --- END: Tab Styling Fix ---
|
||||
|
||||
self.setStyleSheet(base_stylesheet + tab_stylesheet)
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
|
||||
@@ -490,4 +578,98 @@ class FutureSettingsDialog(QDialog):
|
||||
if path_saved or cookie_saved or token_saved:
|
||||
QMessageBox.information(self, "Settings Saved", "Settings have been saved successfully.")
|
||||
else:
|
||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||
QMessageBox.warning(self, "Nothing to Save", "No valid settings were found to save.")
|
||||
|
||||
# --- START: New functions for Save/Load ---
|
||||
def _get_settings_dir(self):
|
||||
"""Helper to get a consistent directory for saving/loading profiles."""
|
||||
if hasattr(self.parent_app, 'user_data_path'):
|
||||
# We use 'user_data_path' which should point to 'appdata'
|
||||
settings_dir = os.path.join(self.parent_app.user_data_path, "settings_profiles")
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
return settings_dir
|
||||
# Fallback if user_data_path isn't available
|
||||
return QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation)
|
||||
|
||||
def _handle_save_settings(self):
|
||||
"""
|
||||
Calls the main app to get all settings, then saves them to a user-chosen JSON file.
|
||||
"""
|
||||
if not hasattr(self.parent_app, '_get_current_ui_settings_as_dict'):
|
||||
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
|
||||
self._tr("settings_missing_save_func_error", "Parent application is missing the required save function."))
|
||||
return
|
||||
|
||||
settings_dir = self._get_settings_dir()
|
||||
filepath, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
self._tr("save_settings_dialog_title", "Save Settings Profile"),
|
||||
settings_dir,
|
||||
self._tr("json_files_filter", "JSON Files (*.json)")
|
||||
)
|
||||
|
||||
if filepath:
|
||||
if not filepath.endswith('.json'):
|
||||
filepath += '.json'
|
||||
|
||||
try:
|
||||
# Get all settings from the main window
|
||||
settings_data = self.parent_app._get_current_ui_settings_as_dict()
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings_data, f, indent=2)
|
||||
|
||||
QMessageBox.information(self,
|
||||
self._tr("save_settings_success_title", "Settings Saved"),
|
||||
self._tr("save_settings_success_msg", "Settings successfully saved to:\n{filename}")
|
||||
.format(filename=os.path.basename(filepath)))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,
|
||||
self._tr("save_settings_error_title", "Error Saving Settings"),
|
||||
str(e))
|
||||
|
||||
def _handle_load_settings(self):
|
||||
"""
|
||||
Lets the user pick a JSON file, loads it, and applies the settings to the main app.
|
||||
"""
|
||||
if not hasattr(self.parent_app, '_load_ui_from_settings_dict') or \
|
||||
not hasattr(self.parent_app, '_update_all_ui_states'):
|
||||
QMessageBox.critical(self, self._tr("generic_error_title", "Error"),
|
||||
self._tr("settings_missing_load_func_error", "Parent application is missing the required load functions."))
|
||||
return
|
||||
|
||||
settings_dir = self._get_settings_dir()
|
||||
filepath, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
self._tr("load_settings_dialog_title", "Load Settings Profile"),
|
||||
settings_dir,
|
||||
self._tr("json_files_filter", "JSON Files (*.json)")
|
||||
)
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
settings_data = json.load(f)
|
||||
|
||||
if not isinstance(settings_data, dict):
|
||||
raise ValueError(self._tr("settings_invalid_json_error", "File is not a valid settings dictionary."))
|
||||
|
||||
# Apply all settings to the main window
|
||||
self.parent_app._load_ui_from_settings_dict(settings_data)
|
||||
|
||||
# Refresh the main window UI to show changes
|
||||
self.parent_app._update_all_ui_states()
|
||||
|
||||
QMessageBox.information(self,
|
||||
self._tr("load_settings_success_title", "Settings Loaded"),
|
||||
self._tr("load_settings_success_msg", "Successfully loaded settings from:\n{filename}")
|
||||
.format(filename=os.path.basename(filepath)))
|
||||
|
||||
# Close the settings dialog after loading
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,
|
||||
self._tr("load_settings_error_title", "Error Loading Settings"),
|
||||
str(e))
|
||||
# --- END: New functions for Save/Load ---
|
||||
@@ -6,7 +6,6 @@ from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QListWidget, QFrame, QWidget, QScrollArea
|
||||
)
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -26,7 +25,8 @@ class TourStepWidget(QWidget):
|
||||
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
# Use a consistent color for titles regardless of theme
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB; padding-bottom: 15px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
@@ -41,17 +41,456 @@ class TourStepWidget(QWidget):
|
||||
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
content_label.setTextFormat(Qt.RichText)
|
||||
content_label.setOpenExternalLinks(True)
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
|
||||
# Set a base line-height and color
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.5;")
|
||||
scroll_area.setWidget(content_label)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
|
||||
class HelpGuideDialog(QDialog):
|
||||
"""A multi-page dialog for displaying the feature guide with a navigation list."""
|
||||
|
||||
def __init__(self, steps_data, parent_app, parent=None):
|
||||
super().__init__(parent)
|
||||
self.steps_data = steps_data
|
||||
self.parent_app = parent_app
|
||||
super().__init__(parent_app)
|
||||
|
||||
self.parent_app = parent_app # This is the main_window instance
|
||||
|
||||
|
||||
self.steps_data = [
|
||||
("Welcome!",
|
||||
"""
|
||||
<p style='font-size: 12pt;'>Welcome to the Kemono Downloader! This guide will walk you through the key features to get you started.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Wide Range of Support</h3>
|
||||
<p>This application provides full, direct download support for several popular sites, including:</p>
|
||||
<ul>
|
||||
<li>Kemono</li>
|
||||
<li>Coomer</li>
|
||||
<li>Bunkr</li>
|
||||
<li>Erome</li>
|
||||
<li>Saint2.su</li>
|
||||
<li>nhentai.net/</li>
|
||||
<li>fap-nation.org/</li>
|
||||
<li>Discord</li>
|
||||
<li>allporncomic.com</li>
|
||||
<li>allporncomic.com</li>
|
||||
<li>hentai2read.com</li>
|
||||
<li>mangadex.org</li>
|
||||
<li>Simpcity</li>
|
||||
<li>gelbooru.com</li>
|
||||
<li>Toonily.com</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Powerful Batch Mode</h3>
|
||||
<p>Save time by downloading hundreds of URLs at once. Simply type <b>nhentai.net</b> or <b>saint2.su</b> into the URL bar. The app will look for a <b>nhentai.txt</b> or <b>saint2.su.txt</b> file in your 'appdata' folder and process all the URLs inside it.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Advanced Discord Support</h3>
|
||||
<p>Go beyond simple file downloading. The app can connect directly to the Discord API to:</p>
|
||||
<ul>
|
||||
<li>Download all files from a specific channel.</li>
|
||||
<li>Save an entire channel's message history as a fully formatted PDF.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Advanced Filtering",
|
||||
"""
|
||||
<p>Control exactly what content you download, from broad categories to specific keywords.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Content Type Filters</h3>
|
||||
<p>These radio buttons let you select the main <i>type</i> of content you want:</p>
|
||||
<ul>
|
||||
<li><b>All:</b> Downloads everything (default).</li>
|
||||
<li><b>Images/GIFs:</b> Only downloads static images and GIFs.</li>
|
||||
<li><b>Videos:</b> Only downloads video files (MP4, WEBM, MOV, etc.).</li>
|
||||
<li><b>Only Archives:</b> Exclusively downloads .zip and .rar files.</li>
|
||||
<li><b>Only Links:</b> Extracts external links (Mega, Google Drive) from post descriptions instead of downloading.</li>
|
||||
<li><b>Only Audio:</b> Only downloads audio files (MP3, WAV, etc.).</li>
|
||||
<li><b>More:</b> Opens a dialog to download post descriptions or comments as text/PDF.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Character Filtering</h3>
|
||||
<p>The <b>"Filter by Character(s)"</b> input is your most powerful tool for targeting content.</p>
|
||||
<ul>
|
||||
<li><b>Basic Use:</b> Enter names, separated by commas (e.g., <code>Tifa, Aerith</code>). This will create folders for "Tifa" and "Aerith" and download posts matching those names.</li>
|
||||
<li><b>Grouped Aliases:</b> Use parentheses to group aliases for a single character (e.g., <code>(Tifa, Lockhart)</code>). This still creates a "Tifa" folder, but it will also match posts that just say "Lockhart".</li>
|
||||
</ul>
|
||||
<p>The <b>"Filter: [Scope]"</b> button changes <i>what</i> is scanned:</p>
|
||||
<ul>
|
||||
<li><b>Filter: Title (Default):</b> Scans only the post's main title.</li>
|
||||
<li><b>Filter: Files:</b> Scans the <i>filenames</i> within the post.</li>
|
||||
<li><b>Filter: Both:</b> Scans both the title and the filenames.</li>
|
||||
<li><b>Filter: Comments (Beta):</b> Scans the post's comment section for the keywords.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Skip Filters (Avoid Content)</h3>
|
||||
<p>The <b>"Skip with Words"</b> input lets you avoid content you don't want.</p>
|
||||
<p>The <b>"Scope: [Scope]"</b> button changes <i>how</i> it skips:</p>
|
||||
<ul>
|
||||
<li><b>Scope: Posts (Default):</b> Skips the <i>entire post</i> if the post's title contains a skip word (e.g., <code>WIP, sketch</code>).</li>
|
||||
<li><b>Scope: Files:</b> Scans and skips <i>individual files</i> if their filename contains a skip word.</li>
|
||||
<li><b>Scope: Both:</b> Skips the post if the title matches, and if not, still checks individual files.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Other Content Options</h3>
|
||||
<ul>
|
||||
<li><b>Skip .zip:</b> A quick toggle to ignore all archive files.</li>
|
||||
<li><b>Download Thumbnails Only:</b> Downloads the small preview image instead of the full-resolution file.</li>
|
||||
<li><b>Scan Content for Images:</b> Scans the post's text description for <code><img></code> tags. Useful for embedded images not in the post's attachment list.</li>
|
||||
<li><b>Keep Duplicates:</b> By default, the app skips files with identical content (hash). Check this to open a dialog and configure it to keep duplicate files.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Filename Control</h3>
|
||||
<p>The <b>"Remove Words from name"</b> input cleans up filenames. Any text you enter here (comma-separated) will be removed from the final saved filename (e.g., <code>patreon, exclusive</code>).</p>
|
||||
"""),
|
||||
|
||||
("Folder Management (Known.txt)",
|
||||
"""
|
||||
<p>This feature, enabled by the <b>"Separate Folders by Known.txt"</b> checkbox, automatically sorts your downloads. It's designed mainly for <b>Kemono</b>, where creators often tag posts with character names in the title.</p>
|
||||
|
||||
<p>When you download from a creator, this feature checks each <b>post title</b> against your `Known.txt` list. If a name matches, a folder is created for that name, and all posts from that creator mentioning the name will be <b>grouped together</b> in that single folder.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Folder Naming Priority</h3>
|
||||
<p>When "Separate Folders" is checked, the app uses this priority to name folders:</p>
|
||||
<ol>
|
||||
<li><b>Character Filter:</b> If you use the <b>"Filter by Character(s)"</b> input (e.g., <code>Tifa</code>), that name is <b>always</b> used as the folder name. This overrides all other rules.</li>
|
||||
<li><b>Known.txt (Post Title):</b> If no filter is used, it checks the <b>post's title</b> for a name in `Known.txt`. (This is the most common use case).</li>
|
||||
<li><b>Known.txt (Filename):</b> If the title doesn't match, it checks all <b>filenames</b> in the post for a match in `Known.txt`.</li>
|
||||
<li><b>Fallback:</b> If no match is found, it creates a generic folder from the post's title.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Editing Your Known.txt File</h3>
|
||||
<p>You can manage this list using the panel on the right of the main window or by clicking <b>"Open Known.txt"</b> to edit it directly. There are two formats:</p>
|
||||
<ul>
|
||||
<li><b>Simple Name:</b><br>
|
||||
<code>Tifa</code><br>
|
||||
This creates a folder named "Tifa" and matches posts/files named "Tifa".
|
||||
</li>
|
||||
<br>
|
||||
<li><b>Grouped Aliases:</b><br>
|
||||
<code>(Tifa, Lockhart)</code><br>
|
||||
This is the most powerful format. It creates a folder named <b>"Tifa Lockhart"</b> and will match posts/files that contain either "Tifa" <i>or</i> "Lockhart". This is perfect for characters with multiple names.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Important Note:</h3>
|
||||
<p>This automatic sorting <b>only works if the creator includes the character names or keywords in the post title</b> (or filename). If they don't, the app has no way of knowing how to sort the post, and it will fall back to a generic folder name.</p>
|
||||
"""),
|
||||
|
||||
("Renaming Mode",
|
||||
"""
|
||||
<p>This mode is designed for downloading comics, manga, or any multi-file post where you need files to be in a specific, sequential order. When active, it downloads posts from <b>oldest to newest</b>.</p>
|
||||
|
||||
<p>Activate it by checking the <b>"Renaming Mode"</b> checkbox. This reveals a new button: <b>"Name: [Style]"</b>. Clicking this button cycles through all available naming conventions.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Available Naming Styles</h3>
|
||||
<ul>
|
||||
<li><b>Post Title:</b> (Default) Files are named after the post's title, with a number for multi-file posts (e.g., <code>My Comic Page_1.jpg</code>, <code>My Comic Page_2.jpg</code>).</li>
|
||||
|
||||
<li><b>Date + Original:</b> Prepends the post's date to the original filename (e.g., <code>2025-11-16_original_file_name.jpg</code>).</li>
|
||||
|
||||
<li><b>Date + Title:</b> Prepends the date to the post title (e.g., <code>2025-11-16_My Comic Page_1.jpg</code>).</li>
|
||||
|
||||
<li><b>Post ID:</b> Names files using the post's unique ID and the file index (e.g., <code>9876543_0.jpg</code>, <code>9876543_1.jpg</code>).</li>
|
||||
|
||||
<li><b>Date Based:</b> Renames all files to a simple, sequential number (e.g., <code>001.jpg</code>, <code>002.jpg</code>). You can add a prefix in the text box that appears (e.g., "Chapter 1 " to get <code>Chapter 1 001.jpg</code>).
|
||||
<br><b style='color: #f0ad4e;'>Note: This mode disables multithreading to guarantee correct file order.</b></li>
|
||||
|
||||
<li><b>Title + G.Num (Global Numbering):</b> Names files by title, but with a *global* counter (e.g., <code>Post A_001.jpg</code>, <code>Post B_002.jpg</code>).
|
||||
<br><b style='color: #f0ad4e;'>Note: This mode also disables multithreading.</b></li>
|
||||
|
||||
<li><b>Custom:</b> Lets you design your own filename using a format string. A <b>"..."</b> button will appear to open the custom format dialog.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Custom Format Placeholders</h3>
|
||||
<p>When using the "Custom" style, you can use these placeholders (click the buttons in the dialog to add them):</p>
|
||||
<ul>
|
||||
<li><code>{id}</code> - The unique ID of the post.</li>
|
||||
<li><code>{creator_name}</code> - The creator's name.</li>
|
||||
<li><code>{service}</code> - The service (e.g., Patreon, Pixiv Fanbox, etc).</li>
|
||||
<li><code>{title}</code> - The title of the post.</li>
|
||||
<li><code>{added}</code> - Date the post was added.</li>
|
||||
<li><code>{published}</code> - Date the post was published.</li>
|
||||
<li><code>{edited}</code> - Date the post was last edited.</li>
|
||||
<li><code>{name}</code> - The original name of the file.</li>
|
||||
</ul>
|
||||
<p>You can also set a custom <b>Date Format</b> (e.g., <code>YYYY-MM-DD</code>) that will apply to the {added}, {published}, and {edited} placeholders.</p>
|
||||
"""),
|
||||
|
||||
("Batch Downloading",
|
||||
"""
|
||||
<p>This feature allows you to download hundreds of URLs from a text file, which is much faster than queuing them one by one.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>How It Works (Step-by-Step)</h3>
|
||||
<ol>
|
||||
<li><b>Find your 'appdata' folder:</b> This is in the same directory as the downloader's <code>.exe</code> file.</li>
|
||||
<li><b>Create a .txt file:</b> Inside the 'appdata' folder, create a text file for the site you want to batch from. The name must be exact. (eg.. nhentai.txt, hentai2read.txt, etc.. )</li>
|
||||
<li><b>Add URLs:</b> Open the <code>.txt</code> file and paste one download URL on each line. Save the file.</li>
|
||||
<li><b>Start the Batch:</b> In the downloader's main URL bar, type the <b>site's domain name</b> (e.g., <code>nhentai.net</code>) and click "Start Download".</li>
|
||||
</ol>
|
||||
<p>The app will automatically find your text file, read all the URLs, and download them sequentially.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Supported Sites and Filenames</h3>
|
||||
<p>The <code>.txt</code> file name must match the site you are triggering:</p>
|
||||
<ul>
|
||||
<li><b>To trigger, type:</b> <code>allporncomic.com</code><br>
|
||||
<b>Text file name:</b> <code>allporncomic.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>nhentai.net</code><br>
|
||||
<b>Text file name:</b> <code>nhentai.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>fap-nation.com</code> or <code>fap-nation.org</code><br>
|
||||
<b>Text file name:</b> <code>fap-nation.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>saint2.su</code><br>
|
||||
<b>Text file name:</b> <code>saint2.su.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>hentai2read.com</code><br>
|
||||
<b>Text file name:</b> <code>hentai2read.txt</code></li>
|
||||
|
||||
<li><b>To trigger, type:</b> <code>rule34video.com</code><br>
|
||||
<b>Text file name:</b> <code>rule34video.txt</code></li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Special Modes: Text & Links",
|
||||
"""
|
||||
<p>These two modes completely change the downloader's function from downloading files to extracting information.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>🔗 Only Links Mode</h3>
|
||||
<p>When you select this, the app <b>stops downloading files</b>. Instead, it scans the post's description for any external URLs (like Mega, Google Drive, Dropbox, etc.) and lists them in the main log.</p>
|
||||
<p>This mode also reveals a new set of tools above the log:</p>
|
||||
<ul>
|
||||
<li><b>Search Bar:</b> Lets you filter the extracted links by keyword (e.g., "mega", "part 1").</li>
|
||||
<li><b>Export Links Button:</b> Opens a dialog to save all the found links to a <code>.txt</code> file.</li>
|
||||
<li><b>Download Button:</b> Opens a new dialog that lets you selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>📄 More (Text Export Mode)</h3>
|
||||
<p>This mode downloads the <b>text content</b> from posts instead of the files. When you select it, a dialog appears asking for more details:</p>
|
||||
<ul>
|
||||
<li><b>Scope:</b>
|
||||
<ul>
|
||||
<li><b>Description/Content:</b> Saves the text from the post's main body.</li>
|
||||
<li><b>Comments:</b> Fetches and saves all the comments from the post.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><b>Export as:</b> You can choose to save the text as a <b>PDF</b>, <b>DOCX</b>, or <b>TXT</b> file.</li>
|
||||
<li><b>Single PDF:</b> (Only available for PDF format) This powerful option stops the app from saving individual PDF files. Instead, it collects the text from <i>all</i> matching posts, sorts them by date, and compiles them into <b>one single, large PDF file</b> at the end of the download session.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Special Commands",
|
||||
"""
|
||||
<p>You can add special commands to the <b>"Filter by Character(s)"</b> input field to change download behavior for a single task. Commands are keywords wrapped in square brackets <code>[]</code>.</p>
|
||||
<p><b>Example:</b> <code>Tifa, (Cloud, Zack) [ao] [sfp-10]</code></p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Filter Commands (in "Filter by Character(s)" input)</h3>
|
||||
<ul>
|
||||
<li><b><code>[ao]</code> (Archive Only Priority)</b><br>
|
||||
This command prioritizes archives.
|
||||
<ul>
|
||||
<li>If a post contains <b>only images/videos</b>, it will download them normally.</li>
|
||||
<li>If a post contains <b>both archives AND images/videos</b>, this command tells the app to <b>only download the archives</b> and skip the other files for that post.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[sfp-N]</code> (Subfolder Per Post Threshold)</b><br>
|
||||
This is an override for when "Subfolder per Post" is <b>OFF</b> (and "Separate Folders by Known.txt" is <b>ON</b>).<br>
|
||||
For example, if you set <code>[sfp-10]</code>:
|
||||
<ul>
|
||||
<li>Posts with <b>less than 10 files</b> will download normally into the main folder (e.g., <code>/ArtistName/</code>).</li>
|
||||
<li>When a post with <b>10 or more files</b> is found, this command will <b>force a subfolder to be created for that one post</b> (e.g., <code>/ArtistName/Comic_Title/</code>) to keep its files grouped together.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[unknown]</code> (Handle Unknown)</b><br>
|
||||
Changes how sorting works when "Separate Folders by Known.txt" is on. If a post title doesn't match any name in your <code>Known.txt</code> list, this command will create a folder using the post's title instead of a generic fallback folder.
|
||||
</li>
|
||||
<br>
|
||||
<li><b><code>[.domain]</code> (Domain Override)</b><br>
|
||||
An advanced command. For example, <code>[.st]</code> forces the app to download from <code>coomer.st</code>, and <code>[.cr]</code> forces it to download from <code>kemono.cr</code>. This can be useful if one domain is blocked or slow.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Skip Command (in "Skip with Words" input)</h3>
|
||||
<p>This command is different and goes into the <b>"Skip with Words"</b> input field, along with any other skip words.</p>
|
||||
<ul>
|
||||
<li><b><code>[N]</code> (Skip File by Size)</b><br>
|
||||
This command skips any file that is <b>smaller</b> than <code>N</code> megabytes (MB).<br>
|
||||
<b>Example:</b> Entering <code>WIP, sketch, [200]</code> into the "Skip with Words" input will skip files with "WIP" or "sketch" in their name, AND it will also skip any file smaller than 200MB.
|
||||
</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Cloud Storage & Direct Links",
|
||||
"""
|
||||
<p>The downloader has built-in support for popular cloud storage and direct-link sites. You can use this in two main ways.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Method 1: Direct URL Download</h3>
|
||||
<p>You can paste a direct link from these services into the main URL bar and hit "Start Download" just like a Kemono link.</p>
|
||||
<ul>
|
||||
<li><b>Pixeldrain:</b> Supports single files (<code>/u/...</code>), albums (<code>/l/...</code>), and folders (<code>/d/...</code>).</li>
|
||||
<li><b>Mega.nz:</b> Supports both single file links (<code>/file/...</code>) and folder links (<code>/folder/...</code>).</li>
|
||||
<li><b>Gofile.io:</b> Supports folder links (<code>/d/...</code>).</li>
|
||||
<li><b>Google Drive:</b> Supports shared folder links.</li>
|
||||
<li><b>Dropbox:</b> Supports shared <code>.zip</code> file links. It will automatically download, extract, and delete the <code>.zip</code> file.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Method 2: "Only Links" Mode Downloader</h3>
|
||||
<p>This is a two-step process for handling posts that have many cloud links in their description.</p>
|
||||
<ol>
|
||||
<li><b>Step 1: Extract Links</b><br>
|
||||
Select the <b>"🔗 Only Links"</b> radio button and run a download on a creator or post page. The app will scan all posts and list the external links (Mega, GDrive, etc.) it finds in the log.
|
||||
</li>
|
||||
<br>
|
||||
<li><b>Step 2: Download Links</b><br>
|
||||
After extraction, a <b>"Download"</b> button (next to "Export Links") will become active. This opens a new window where you can selectively download from the supported links (Mega, Google Drive, Dropbox) that were found.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Note: SimpCity Integration</h3>
|
||||
<p>SimpCity support relies heavily on this feature. When you download from a SimpCity thread, the app <b>automatically</b> scans the page for links to services like <b>Pixeldrain, Bunkr, Saint2, Mega, and Gofile</b> and then downloads them just as if you had put in those links directly. You can control which of these services are downloaded from the checkboxes in the "SimpCity Settings" section of the main window.</p>
|
||||
"""),
|
||||
|
||||
("Creator Selection & Updates",
|
||||
"""
|
||||
<p>Clicking the <b>🎨 button</b> (next to the URL bar) opens the <b>Creator Selection</b> dialog. This is your control for managing creators you've already downloaded from.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Main List & Searching</h3>
|
||||
<p>The main list shows all creators from your <code>creators.json</code> file. You can:</p>
|
||||
<ul>
|
||||
<li><b>Search:</b> The top search bar filters your creators by name, service, or even a direct URL.</li>
|
||||
<li><b>Select:</b> Check the boxes next to creators to select them for an action.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Action Buttons</h3>
|
||||
|
||||
<p><b>Check for Updates</b></p>
|
||||
<p>This button opens a new window, "Check for Updates," which lists all your <b>Creator Profiles</b> (the <code>.json</code> files saved in your <code>appdata/creator_profiles</code> folder). These profiles are created automatically when you download a full creator page.</p>
|
||||
<p>From this dialog, you can check multiple creators at once. The app will scan all of them and then show a final "Start Download" button on the main window to download <i>only</i> the new posts, using the same settings you used for each creator last time.</p>
|
||||
|
||||
<p><b>Add Selected</b></p>
|
||||
<p>This is the simplest action. It takes all the creators you've checked, puts their names in the main URL bar, and closes the dialog. This is a quick way to add multiple creators to the download queue for a download.</p>
|
||||
|
||||
<p><b>Fetch Posts</b></p>
|
||||
<p>This is a powerful tool for finding specific posts. When you click it:</p>
|
||||
<ol>
|
||||
<li>The dialog expands, and a new panel appears on the right.</li>
|
||||
<li>The app fetches <i>every single post</i> from all the creators you selected. This may take time.</li>
|
||||
<li>The right panel fills with a list of all posts, grouped by creator.</li>
|
||||
<li>You can now search this list and check the boxes next to the <i>individual posts</i> you want.</li>
|
||||
<li>Clicking <b>"Add Selected Posts to Queue"</b> adds only those specific posts to the download queue.</li>
|
||||
</ol>
|
||||
"""),
|
||||
|
||||
("⭐ Favorite Mode",
|
||||
"""
|
||||
<p>This mode is a powerful feature for downloading directly from your personal <b>Kemono</b> and <b>Coomer</b> favorites lists. It requires you to be logged in on your browser and to provide your cookies to the app.</p>
|
||||
|
||||
<p><b style='color: #f0ad4e;'>Important:</b> You <b>must</b> check the <b>"Use Cookie"</b> box and provide a valid cookie for this mode to work. If cookies are missing or invalid, the app will show you a help dialog.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>How to Use Favorite Mode</h3>
|
||||
<ol>
|
||||
<li>Check the <b>"⭐ Favorite Mode"</b> checkbox on the main window. This will lock the URL bar and show two new buttons.</li>
|
||||
<li>Click either <b>"🖼️ Favorite Artists"</b> or <b>"📄 Favorite Posts"</b>.</li>
|
||||
<li>A new dialog will open and begin fetching all your favorites from both Kemono and Coomer at the same time.</li>
|
||||
<li>Once loaded, you can search, filter, and select the artists or posts you want to download.</li>
|
||||
<li>Click "Download Selected" to add them to the main download queue and begin processing.</li>
|
||||
</ol>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Favorite Artists</h3>
|
||||
<p>The <b>"Favorite Artists"</b> dialog will load your list of followed creators. When you download from here, the app treats it as a full creator download, just as if you had pasted in that artist's URL.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Favorite Posts</h3>
|
||||
<p>The <b>"Favorite Posts"</b> dialog loads a list of every individual post you have favorited. This dialog has some extra features:</p>
|
||||
<ul>
|
||||
<li><b>Creator Name Resolution:</b> It attempts to match the post's creator ID with the names in your <code>creators.json</code> file to show you a recognizable name.</li>
|
||||
<li><b>Known.txt Matching:</b> It highlights posts by showing <code>[Known - Tifa]</code> in the title if the post title matches an entry in your <code>Known.txt</code> list, helping you find specific content.</li>
|
||||
<li><b>Grouping:</b> Posts are automatically grouped by creator to keep the list organized.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Download Scope (Artist Folders)</h3>
|
||||
<p>In Favorite Mode, the <b>"Scope: [Location]"</b> button becomes very important. It controls <i>where</i> your favorited items are saved:</p>
|
||||
<ul>
|
||||
<li><b>Scope: Selected Location (Default):</b> Downloads all selected items directly into the main "Download Location" folder you have set.</li>
|
||||
<li><b>Scope: Artist Folders:</b> This automatically creates a new subfolder for each artist inside your main "Download Location" (e.g., <code>/Downloads/ArtistName/</code>). This is the best way to keep your favorites organized.</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("File & Download Options",
|
||||
"""
|
||||
<p>These checkboxes give you fine-grained control over which files are downloaded and how they are processed.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>File Type & Content</h3>
|
||||
<ul>
|
||||
<li><b>Skip .zip:</b> A simple toggle. When checked, the downloader will skip all <code>.zip</code> and <code>.rar</code> archive files it finds.</li>
|
||||
<br>
|
||||
<li><b>Scan Content for Images:</b> This is a powerful feature for posts where images are embedded in the description (<code><img></code> tags) but not listed as attachments. When checked, the app will scan the post's HTML content and try to find and download these embedded images.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Image Processing</h3>
|
||||
<ul>
|
||||
<li><b>Download Thumbnails Only:</b> Saves bandwidth and time by downloading the small preview/thumbnail version of an image instead of the full-resolution file.</li>
|
||||
<br>
|
||||
<li><b>Compress to WebP:</b> If an image is over 1.5MB, this option will automatically convert it to the <code>.webp</code> format during the download, which significantly reduces file size while maintaining high quality.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Duplicate Handling</h3>
|
||||
<ul>
|
||||
<li><b>Keep Duplicates:</b> By default, the app checks the <i>content</i> (hash) of a file and will not re-download a file it already has. Checking this box opens a dialog with more options:
|
||||
<ul>
|
||||
<li><b>Hash (Default):</b> The standard behavior.</li>
|
||||
<li><b>Keep Everything:</b> Disables all duplicate checks and downloads every file from the API, even if you already have it.</li>
|
||||
<li><b>Limit:</b> Lets you set a limit (e.g., 2) to how many times a file with the same content can be downloaded.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
"""),
|
||||
|
||||
("Utility & Advanced Options",
|
||||
"""
|
||||
<p>These features provide advanced control over your downloads, sessions, and application settings.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Use Cookie</h3>
|
||||
<p>This is essential for downloading from sites that require a login (like <b>SimpCity</b> or accessing your <b>favorites</b> on Kemono/Coomer). You can either:</p>
|
||||
<ul>
|
||||
<li><b>Paste a cookie string:</b> Copy the "cookie" value from your browser's developer tools and paste it into the text field.</li>
|
||||
<li><b>Use a file:</b> Click the "Browse" button to select a <code>cookies.txt</code> file exported from your browser.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Page Range</h3>
|
||||
<p>When downloading from a creator's main page (not a single post), these "Start" and "End" fields let you limit the download. For example, entering <code>Start: 1</code> and <code>End: 5</code> will only download posts from the first five pages.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Multi-part Download</h3>
|
||||
<p>Clicking the <b>"Multi-part: OFF"</b> button opens a dialog to enable high-speed downloads for large files. It will split a large file into multiple parts and download them at the same time. You can choose to apply this to videos, archives, or both, and set the minimum file size to trigger it.</p>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Download History</h3>
|
||||
<p>The <b>"History"</b> button opens a dialog showing two lists:</p>
|
||||
<ul>
|
||||
<li><b>Last 3 Files:</b> The last 3 individual files you successfully downloaded.</li>
|
||||
<li><b>First 3 Posts:</b> The first 3 posts Processed from your *most recent* download session.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Settings (Gear Icon)</h3>
|
||||
<p>The <b>Gear</b> icon ⚙️ opens the main application settings, which is now organized into tabs:</p>
|
||||
<ul>
|
||||
<li><b>Display Tab:</b> Change the app's <b>Theme</b> (Light/Dark), <b>Language</b>, <b>UI Scale</b>, and default <b>Window Size</b>.</li>
|
||||
<li><b>Downloads Tab:</b>
|
||||
<ul>
|
||||
<li>Save your current <b>Download Path</b>, <b>Cookie</b>, and <b>Discord Token</b> for future sessions using the "Save Path + Cookie + Token" button.</li>
|
||||
<li>Set an <b>Action After Download</b> (e.g., Notify, Sleep, Shutdown).</li>
|
||||
<li>Customize the <b>Post Subfolder Format</b> for when the date prefix is used (e.g., <code>YYYY-MM-DD {post}</code>).</li>
|
||||
<li>Toggle <b>"Save Creator.json file"</b> (which enables the "Check for Updates" feature).</li>
|
||||
<li>Toggle <b>"Fetch First"</b> (to find all posts from a creator before starting any downloads).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><b>Updates Tab:</b> Check for and install new application updates.</li>
|
||||
</ul>
|
||||
|
||||
<h3 style='color: #E0E0E0;'>Reset Button</h3>
|
||||
<p>The <b>"Reset"</b> button (bottom right) is a soft reset. It clears all input fields (except your Download Location), clears the logs, and resets all download options and filters back to their default state. It does <b>not</b> clear your Download History or saved Settings.</p>
|
||||
""")
|
||||
]
|
||||
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
|
||||
@@ -66,7 +505,38 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
current_theme_style = ""
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
base_style = get_dark_theme(scale)
|
||||
|
||||
list_widget_style = f"""
|
||||
QListWidget {{
|
||||
background-color: #2E2E2E;
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
color: #DCDCDC;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #4A4A4A;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #87CEEB;
|
||||
color: #1E1E1E;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: #3A3A3A;
|
||||
}}
|
||||
|
||||
/* Style for the TourStepWidget content */
|
||||
TourStepWidget QLabel {{
|
||||
color: #DCDCDC;
|
||||
}}
|
||||
TourStepWidget QScrollArea {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
"""
|
||||
current_theme_style = base_style + list_widget_style
|
||||
else:
|
||||
# Basic light theme fallback
|
||||
current_theme_style = f"""
|
||||
@@ -83,29 +553,50 @@ class HelpGuideDialog(QDialog):
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #CACACA; }}
|
||||
QPushButton:pressed {{ background-color: #B0B0B0; }}
|
||||
QListWidget {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #C0C0C0;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
color: #1E1E1E;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #0078D7;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: #F0F0F0;
|
||||
}}
|
||||
TourStepWidget QLabel {{
|
||||
color: #1E1E1E;
|
||||
}}
|
||||
TourStepWidget h3 {{
|
||||
color: #005A9E;
|
||||
}}
|
||||
"""
|
||||
|
||||
self.setStyleSheet(current_theme_style)
|
||||
self._init_ui()
|
||||
|
||||
if self.parent_app:
|
||||
self.move(self.parent_app.geometry().center() - self.rect().center())
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _init_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(15, 15, 15, 15)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# Title
|
||||
title_label = QLabel(self._tr("help_guide_dialog_title", "Kemono Downloader - Feature Guide"))
|
||||
title_label = QLabel("Kemono Downloader - Feature Guide")
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
title_font_size = int(16 * scale)
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0;")
|
||||
# Use a consistent color for the main title
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #87CEEB;")
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title_label)
|
||||
|
||||
@@ -115,34 +606,14 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
self.nav_list = QListWidget()
|
||||
self.nav_list.setFixedWidth(int(220 * scale))
|
||||
self.nav_list.setStyleSheet(f"""
|
||||
QListWidget {{
|
||||
background-color: #2E2E2E;
|
||||
border: 1px solid #4A4A4A;
|
||||
border-radius: 4px;
|
||||
font-size: {int(11 * scale)}pt;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #4A4A4A;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: #87CEEB;
|
||||
color: #2E2E2E;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
# Styles are now set in the __init__ method
|
||||
content_layout.addWidget(self.nav_list)
|
||||
|
||||
self.stacked_widget = QStackedWidget()
|
||||
content_layout.addWidget(self.stacked_widget)
|
||||
|
||||
for title_key, content_key in self.steps_data:
|
||||
title = self._tr(title_key, title_key)
|
||||
content = self._tr(content_key, f"Content for {content_key} not found.")
|
||||
|
||||
for title, content in self.steps_data:
|
||||
self.nav_list.addItem(title)
|
||||
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
@@ -171,13 +642,19 @@ class HelpGuideDialog(QDialog):
|
||||
icon_dim = int(24 * scale)
|
||||
icon_size = QSize(icon_dim, icon_dim)
|
||||
|
||||
tooltip_map = {
|
||||
"help_guide_github_tooltip": "Visit the project on GitHub",
|
||||
"help_guide_instagram_tooltip": "Follow the developer on Instagram",
|
||||
"help_guide_discord_tooltip": "Join the official Discord server"
|
||||
}
|
||||
|
||||
for button, tooltip_key, url in [
|
||||
(self.github_button, "help_guide_github_tooltip", "https://github.com/Yuvi63771/Kemono-Downloader"),
|
||||
(self.instagram_button, "help_guide_instagram_tooltip", "https://www.instagram.com/uvi.arts/"),
|
||||
(self.discord_button, "help_guide_discord_tooltip", "https://discord.gg/BqP64XTdJN")
|
||||
]:
|
||||
button.setIconSize(icon_size)
|
||||
button.setToolTip(self._tr(tooltip_key))
|
||||
button.setToolTip(tooltip_map.get(tooltip_key, ""))
|
||||
button.setFixedSize(icon_size.width() + 8, icon_size.height() + 8)
|
||||
button.setStyleSheet("background-color: transparent; border: none;")
|
||||
button.clicked.connect(lambda _, u=url: QDesktopServices.openUrl(QUrl(u)))
|
||||
@@ -185,7 +662,7 @@ class HelpGuideDialog(QDialog):
|
||||
|
||||
footer_layout.addStretch(1)
|
||||
|
||||
self.finish_button = QPushButton(self._tr("tour_dialog_finish_button", "Finish"))
|
||||
self.finish_button = QPushButton("Finish")
|
||||
self.finish_button.clicked.connect(self.accept)
|
||||
footer_layout.addWidget(self.finish_button)
|
||||
|
||||
|
||||
179
src/ui/dialogs/UpdateCheckDialog.py
Normal file
179
src/ui/dialogs/UpdateCheckDialog.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# --- Standard Library Imports ---
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
|
||||
QPushButton, QMessageBox, QAbstractItemView, QLabel
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
class UpdateCheckDialog(QDialog):
|
||||
"""
|
||||
A dialog that lists all creator .json profiles with checkboxes
|
||||
and allows the user to select multiple to check for updates.
|
||||
"""
|
||||
|
||||
def __init__(self, user_data_path, parent_app_ref, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_app = parent_app_ref
|
||||
self.user_data_path = user_data_path
|
||||
self.selected_profiles_list = [] # Will store a list of {'name': ..., 'data': ...}
|
||||
|
||||
self._init_ui()
|
||||
self._load_profiles()
|
||||
self._retranslate_ui()
|
||||
|
||||
# Apply theme from parent
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
self.setStyleSheet("")
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initializes the UI components."""
|
||||
self.setWindowTitle("Check for Updates")
|
||||
self.setMinimumSize(400, 450)
|
||||
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.info_label = QLabel("Select creator profiles to check for updates:")
|
||||
layout.addWidget(self.info_label)
|
||||
|
||||
# --- List Widget with Checkboxes ---
|
||||
self.list_widget = QListWidget()
|
||||
# No selection mode, we only care about checkboxes
|
||||
self.list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
# --- All Buttons in One Horizontal Layout ---
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6) # small even spacing between all buttons
|
||||
|
||||
self.select_all_button = QPushButton("Select All")
|
||||
self.select_all_button.clicked.connect(self._toggle_all_checkboxes)
|
||||
|
||||
self.deselect_all_button = QPushButton("Deselect All")
|
||||
self.deselect_all_button.clicked.connect(self._toggle_all_checkboxes)
|
||||
|
||||
self.close_button = QPushButton("Close")
|
||||
self.close_button.clicked.connect(self.reject)
|
||||
|
||||
self.check_button = QPushButton("Check Selected")
|
||||
self.check_button.clicked.connect(self.on_check_selected)
|
||||
self.check_button.setDefault(True)
|
||||
|
||||
# Add buttons without a stretch (so no large gap)
|
||||
button_layout.addWidget(self.select_all_button)
|
||||
button_layout.addWidget(self.deselect_all_button)
|
||||
button_layout.addWidget(self.close_button)
|
||||
button_layout.addWidget(self.check_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _tr(self, key, default_text=""):
|
||||
"""Helper to get translation based on current app language."""
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
return default_text
|
||||
|
||||
def _retranslate_ui(self):
|
||||
"""Translates the UI elements."""
|
||||
self.setWindowTitle(self._tr("update_check_dialog_title", "Check for Updates"))
|
||||
self.info_label.setText(self._tr("update_check_dialog_info_multiple", "Select creator profiles to check for updates:"))
|
||||
self.select_all_button.setText(self._tr("select_all_button_text", "Select All"))
|
||||
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
|
||||
self.check_button.setText(self._tr("update_check_dialog_check_button", "Check Selected"))
|
||||
self.close_button.setText(self._tr("update_check_dialog_close_button", "Close"))
|
||||
|
||||
def _load_profiles(self):
|
||||
"""Loads all .json files from the creator_profiles directory as checkable items."""
|
||||
appdata_dir = self.user_data_path
|
||||
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
|
||||
|
||||
if not os.path.isdir(profiles_dir):
|
||||
QMessageBox.warning(self,
|
||||
self._tr("update_check_dir_not_found_title", "Directory Not Found"),
|
||||
self._tr("update_check_dir_not_found_msg",
|
||||
"The creator profiles directory does not exist yet.\n\nPath: {path}")
|
||||
.format(path=profiles_dir))
|
||||
return
|
||||
|
||||
profiles_found = []
|
||||
for filename in os.listdir(profiles_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(profiles_dir, filename)
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Basic validation to ensure it's a valid profile
|
||||
if 'creator_url' in data and 'processed_post_ids' in data:
|
||||
creator_name = os.path.splitext(filename)[0]
|
||||
profiles_found.append({'name': creator_name, 'data': data})
|
||||
else:
|
||||
print(f"Skipping invalid profile: {filename}")
|
||||
except Exception as e:
|
||||
print(f"Failed to load profile {filename}: {e}")
|
||||
|
||||
profiles_found.sort(key=lambda x: x['name'].lower())
|
||||
|
||||
for profile_info in profiles_found:
|
||||
item = QListWidgetItem(profile_info['name'])
|
||||
item.setData(Qt.UserRole, profile_info)
|
||||
# --- Make item checkable ---
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
if not profiles_found:
|
||||
self.list_widget.addItem(self._tr("update_check_no_profiles", "No creator profiles found."))
|
||||
self.list_widget.setEnabled(False)
|
||||
self.check_button.setEnabled(False)
|
||||
self.select_all_button.setEnabled(False)
|
||||
self.deselect_all_button.setEnabled(False)
|
||||
|
||||
def _toggle_all_checkboxes(self):
|
||||
"""Handles Select All and Deselect All button clicks."""
|
||||
sender = self.sender()
|
||||
check_state = Qt.Checked if sender == self.select_all_button else Qt.Unchecked
|
||||
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setCheckState(check_state)
|
||||
|
||||
def on_check_selected(self):
|
||||
"""Handles the 'Check Selected' button click."""
|
||||
self.selected_profiles_list = []
|
||||
|
||||
for i in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(i)
|
||||
if item.checkState() == Qt.Checked:
|
||||
profile_info = item.data(Qt.UserRole)
|
||||
if profile_info:
|
||||
self.selected_profiles_list.append(profile_info)
|
||||
|
||||
if not self.selected_profiles_list:
|
||||
QMessageBox.warning(self,
|
||||
self._tr("update_check_no_selection_title", "No Selection"),
|
||||
self._tr("update_check_no_selection_msg", "Please select at least one creator to check."))
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_selected_profiles(self):
|
||||
"""Returns the list of profile data selected by the user."""
|
||||
return self.selected_profiles_list
|
||||
@@ -104,6 +104,7 @@ from .classes.drive_downloader_thread import DriveDownloadThread
|
||||
from .classes.external_link_downloader_thread import ExternalLinkDownloadThread
|
||||
from .classes.nhentai_downloader_thread import NhentaiDownloadThread
|
||||
from .classes.downloader_factory import create_downloader_thread
|
||||
from .classes.kemono_discord_downloader_thread import KemonoDiscordDownloadThread
|
||||
|
||||
_ff_ver = (datetime.date.today().toordinal() - 735506) // 28
|
||||
USERAGENT_FIREFOX = (f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; "
|
||||
@@ -148,6 +149,7 @@ class DownloaderApp (QWidget ):
|
||||
external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
|
||||
file_progress_signal =pyqtSignal (str ,object )
|
||||
fetch_only_complete_signal = pyqtSignal(list)
|
||||
batch_update_check_complete_signal = pyqtSignal(list)
|
||||
|
||||
|
||||
def __init__(self):
|
||||
@@ -155,6 +157,10 @@ class DownloaderApp (QWidget ):
|
||||
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
|
||||
self.active_update_profile = None
|
||||
self.new_posts_for_update = []
|
||||
|
||||
self.active_update_profiles_list = [] # For batch updates
|
||||
self.fetched_posts_for_batch_update = [] # Stores {'post_data': ..., 'creator_settings': ...}
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.is_finishing = False
|
||||
self.finish_lock = threading.Lock()
|
||||
|
||||
@@ -333,16 +339,14 @@ class DownloaderApp (QWidget ):
|
||||
self.download_location_label_widget = None
|
||||
self.remove_from_filename_label_widget = None
|
||||
self.skip_words_label_widget = None
|
||||
self.setWindowTitle("Kemono Downloader v7.5.1")
|
||||
self.setWindowTitle("Kemono Downloader v6.7.0")
|
||||
setup_ui(self)
|
||||
self._connect_signals()
|
||||
if hasattr(self, 'character_input'):
|
||||
self.character_input.setToolTip(self._tr("character_input_tooltip", "Enter character names (comma-separated)..."))
|
||||
self.log_signal.emit(f"ℹ️ Manga filename style loaded: '{self.manga_filename_style}'")
|
||||
self.log_signal.emit(f"ℹ️ filename style loaded: '{self.manga_filename_style}'")
|
||||
self.log_signal.emit(f"ℹ️ Skip words scope loaded: '{self.skip_words_scope}'")
|
||||
self.log_signal.emit(f"ℹ️ Character filter scope set to default: '{self.char_filter_scope}'")
|
||||
self.log_signal.emit(f"ℹ️ Multi-part download defaults to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'}")
|
||||
self.log_signal.emit(f"ℹ️ Scan post content for images defaults to: {'Enabled' if self.scan_content_images_setting else 'Disabled'}")
|
||||
self.log_signal.emit(f"ℹ️ Application language loaded: '{self.current_selected_language.upper()}' (UI may not reflect this yet).")
|
||||
self._retranslate_main_ui()
|
||||
self._load_persistent_history()
|
||||
@@ -494,6 +498,8 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def _connect_specialized_thread_signals(self, thread):
|
||||
"""Connects common signals for specialized downloader threads."""
|
||||
|
||||
is_kemono_discord = isinstance(thread, KemonoDiscordDownloadThread)
|
||||
if hasattr(thread, 'progress_signal'):
|
||||
thread.progress_signal.connect(self.handle_main_log)
|
||||
if hasattr(thread, 'file_progress_signal'):
|
||||
@@ -508,6 +514,10 @@ class DownloaderApp (QWidget ):
|
||||
if hasattr(thread, 'progress_label_signal'): # For Discord thread
|
||||
thread.progress_label_signal.connect(self.progress_label.setText)
|
||||
|
||||
if is_kemono_discord and hasattr(thread, 'permanent_file_failed_signal'):
|
||||
thread.permanent_file_failed_signal.connect(self._handle_permanent_file_failure_from_thread)
|
||||
print("DEBUG: Connected permanent_file_failed_signal for KemonoDiscordDownloadThread.") # Debug print
|
||||
|
||||
def _apply_theme_and_restart_prompt(self):
|
||||
"""Applies the theme and prompts the user to restart."""
|
||||
if self.current_theme == "dark":
|
||||
@@ -770,6 +780,17 @@ class DownloaderApp (QWidget ):
|
||||
self.cancel_btn.clicked.connect(self.reset_application_state)
|
||||
return # <-- This 'return' is CRITICAL
|
||||
|
||||
elif self.is_ready_to_download_batch_update:
|
||||
num_posts = len(self.fetched_posts_for_batch_update)
|
||||
self.download_btn.setText(f"⬇️ Start Download ({num_posts} New Posts)")
|
||||
self.download_btn.setEnabled(True)
|
||||
self.download_btn.clicked.connect(self.start_download)
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setText("🗑️ Clear Update")
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.clicked.connect(self.reset_application_state)
|
||||
return
|
||||
|
||||
if self.active_update_profile and self.new_posts_for_update and not is_download_active:
|
||||
# State: Update confirmation (new posts found, waiting for user to start)
|
||||
num_new = len(self.new_posts_for_update)
|
||||
@@ -824,14 +845,11 @@ class DownloaderApp (QWidget ):
|
||||
self.download_btn.setEnabled(False)
|
||||
self.pause_btn.setEnabled(False)
|
||||
else:
|
||||
# --- START MODIFICATION ---
|
||||
# Check if we are about to download fetched posts and update text accordingly
|
||||
if self.is_ready_to_download_fetched:
|
||||
num_posts = len(self.fetched_posts_for_download)
|
||||
self.download_btn.setText(f"⬇️ Start Download ({num_posts} Posts)")
|
||||
self.download_btn.setEnabled(True) # Keep it enabled for the user to click
|
||||
else:
|
||||
# Original logic for an active download in other scenarios
|
||||
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
|
||||
self.download_btn.setEnabled(False)
|
||||
|
||||
@@ -919,11 +937,9 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
args_template = self.last_start_download_args
|
||||
|
||||
# Update both the character filter list and the domain override in the arguments
|
||||
args_template['filter_character_list'] = parsed_filters
|
||||
args_template['domain_override'] = domain_override
|
||||
|
||||
# Manually set the UI to a "downloading" state for reliability
|
||||
self.set_ui_enabled(False)
|
||||
self.download_btn.setText("⬇️ Downloading...")
|
||||
self.download_btn.setEnabled(False)
|
||||
@@ -931,7 +947,6 @@ class DownloaderApp (QWidget ):
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.setText("❌ Cancel & Reset UI")
|
||||
try:
|
||||
# Ensure signals are connected to the correct actions for this state
|
||||
self.cancel_btn.clicked.disconnect()
|
||||
self.pause_btn.clicked.disconnect()
|
||||
except TypeError:
|
||||
@@ -1131,6 +1146,7 @@ class DownloaderApp (QWidget ):
|
||||
self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
|
||||
self.actual_gui_signals.worker_finished_signal.connect(self._handle_worker_result)
|
||||
self .actual_gui_signals .file_download_status_signal .connect (lambda status :None )
|
||||
self.batch_update_check_complete_signal.connect(self._batch_update_check_finished)
|
||||
self.fetch_only_complete_signal.connect(self._fetch_only_finished)
|
||||
|
||||
if hasattr (self ,'character_input'):
|
||||
@@ -1796,7 +1812,9 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
supported_platforms_for_button ={'mega','google drive','dropbox'}
|
||||
has_supported_links =any (
|
||||
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
|
||||
link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
|
||||
for link_info in self.extracted_links_cache
|
||||
|
||||
)
|
||||
self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links )
|
||||
|
||||
@@ -3184,8 +3202,7 @@ class DownloaderApp (QWidget ):
|
||||
self .update_custom_folder_visibility ()
|
||||
self .update_page_range_enabled_state ()
|
||||
if self .manga_mode_checkbox :
|
||||
self .manga_mode_checkbox .setChecked (False )
|
||||
self .manga_mode_checkbox .setEnabled (False )
|
||||
pass
|
||||
if hasattr (self ,'use_cookie_checkbox'):
|
||||
self .use_cookie_checkbox .setChecked (True )
|
||||
self .use_cookie_checkbox .setEnabled (False )
|
||||
@@ -3246,9 +3263,7 @@ class DownloaderApp (QWidget ):
|
||||
if self.favorite_download_queue and all(item.get('type') == 'single_post_from_popup' for item in self.favorite_download_queue):
|
||||
is_single_post = True
|
||||
|
||||
# --- MODIFIED: Added check for is_discord_url ---
|
||||
can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on and not is_discord_url
|
||||
|
||||
can_enable_manga_checkbox = (is_favorite_mode_on or not is_discord_url)
|
||||
if self .manga_mode_checkbox :
|
||||
self .manga_mode_checkbox .setEnabled (can_enable_manga_checkbox)
|
||||
if not can_enable_manga_checkbox and self .manga_mode_checkbox .isChecked ():
|
||||
@@ -3518,6 +3533,18 @@ class DownloaderApp (QWidget ):
|
||||
return get_theme_stylesheet(actual_scale)
|
||||
|
||||
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
|
||||
|
||||
if not is_restore and not is_continuation:
|
||||
if self.main_log_output: self.main_log_output.clear()
|
||||
if self.external_log_output: self.external_log_output.clear()
|
||||
if self.missed_character_log_output: self.missed_character_log_output.clear()
|
||||
self.missed_key_terms_buffer.clear()
|
||||
self.already_logged_bold_key_terms.clear()
|
||||
|
||||
if self.is_ready_to_download_batch_update:
|
||||
self._start_download_of_batch_update()
|
||||
return True
|
||||
|
||||
if not direct_api_url:
|
||||
api_url_text = self.link_input.text().strip().lower()
|
||||
batch_handlers = {
|
||||
@@ -3860,30 +3887,6 @@ class DownloaderApp (QWidget ):
|
||||
num_threads_from_gui = MAX_THREADS
|
||||
self.thread_count_input.setText(str(MAX_THREADS))
|
||||
self.log_signal.emit(f"⚠️ User attempted {num_threads_from_gui} threads, capped to {MAX_THREADS}.")
|
||||
if SOFT_WARNING_THREAD_THRESHOLD < num_threads_from_gui <= MAX_THREADS:
|
||||
soft_warning_msg_box = QMessageBox(self)
|
||||
soft_warning_msg_box.setIcon(QMessageBox.Question)
|
||||
soft_warning_msg_box.setWindowTitle("Thread Count Advisory")
|
||||
soft_warning_msg_box.setText(
|
||||
f"You've set the thread count to {num_threads_from_gui}.\n\n"
|
||||
"While this is within the allowed limit, using a high number of threads (typically above 40-50) can sometimes lead to:\n"
|
||||
" - Increased errors or failed file downloads.\n"
|
||||
" - Connection issues with the server.\n"
|
||||
" - Higher system resource usage.\n\n"
|
||||
"For most users and connections, 10-30 threads provide a good balance.\n\n"
|
||||
f"Do you want to proceed with {num_threads_from_gui} threads, or would you like to change the value?"
|
||||
)
|
||||
proceed_button = soft_warning_msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
|
||||
change_button = soft_warning_msg_box.addButton("Change Thread Value", QMessageBox.RejectRole)
|
||||
soft_warning_msg_box.setDefaultButton(proceed_button)
|
||||
soft_warning_msg_box.setEscapeButton(change_button)
|
||||
soft_warning_msg_box.exec_()
|
||||
|
||||
if soft_warning_msg_box.clickedButton() == change_button:
|
||||
self.log_signal.emit(f"ℹ️ User opted to change thread count from {num_threads_from_gui} after advisory.")
|
||||
self.thread_count_input.setFocus()
|
||||
self.thread_count_input.selectAll()
|
||||
return False
|
||||
|
||||
raw_skip_words_text = self.skip_words_input.text().strip()
|
||||
skip_words_parts = [part.strip() for part in raw_skip_words_text.split(',') if part.strip()]
|
||||
@@ -3990,26 +3993,6 @@ class DownloaderApp (QWidget ):
|
||||
if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.")
|
||||
if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.")
|
||||
|
||||
if manga_mode and start_page and end_page:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setWindowTitle("Renaming Mode & Page Range Warning")
|
||||
msg_box.setText(
|
||||
"You have enabled <b>Renaming Mode</b> with a sequential naming style (<b>Date Based</b> or <b>Title + G.Num</b>) and also specified a <b>Page Range</b>.\n\n"
|
||||
"These modes rely on processing all posts from the beginning to create a correct sequence. "
|
||||
"Using a page range may result in an incomplete or incorrectly ordered download.\n\n"
|
||||
"It is recommended to use these styles without a page range.\n\n"
|
||||
"Do you want to proceed anyway?"
|
||||
)
|
||||
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
|
||||
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
|
||||
msg_box.setDefaultButton(proceed_button)
|
||||
msg_box.setEscapeButton(cancel_button)
|
||||
msg_box.exec_()
|
||||
|
||||
if msg_box.clickedButton() == cancel_button:
|
||||
self.log_signal.emit("❌ Download cancelled by user due to Renaming Mode & Page Range warning.")
|
||||
return False
|
||||
except ValueError as e:
|
||||
QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}")
|
||||
return False
|
||||
@@ -4414,6 +4397,7 @@ class DownloaderApp (QWidget ):
|
||||
if self.pause_event: self.pause_event.clear()
|
||||
self.is_paused = False
|
||||
return True
|
||||
|
||||
def restore_download(self):
|
||||
"""Initiates the download restoration process."""
|
||||
if self._is_download_active():
|
||||
@@ -4577,6 +4561,294 @@ class DownloaderApp (QWidget ):
|
||||
self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
|
||||
self._update_error_button_count()
|
||||
|
||||
def _start_batch_update_check(self, profiles_list):
|
||||
"""Launches a background thread to check multiple profiles for updates."""
|
||||
self.set_ui_enabled(False)
|
||||
self.progress_label.setText(self._tr("batch_update_checking", "Checking for updates..."))
|
||||
self.cancellation_event.clear()
|
||||
|
||||
# Start the background thread
|
||||
self.download_thread = threading.Thread(
|
||||
target=self._run_batch_update_check_thread,
|
||||
args=(profiles_list,),
|
||||
daemon=True
|
||||
)
|
||||
self.download_thread.start()
|
||||
self._update_button_states_and_connections()
|
||||
|
||||
def _run_batch_update_check_thread(self, profiles_list):
|
||||
"""
|
||||
(BACKGROUND THREAD)
|
||||
Iterates profiles, calls download_from_api for each, and collects new posts.
|
||||
"""
|
||||
master_new_post_list = []
|
||||
total_profiles = len(profiles_list)
|
||||
|
||||
for i, profile in enumerate(profiles_list):
|
||||
if self.cancellation_event.is_set():
|
||||
break
|
||||
|
||||
profile_name = profile.get('name', 'Unknown')
|
||||
self.log_signal.emit(f"Checking {profile_name} ({i+1}/{total_profiles})...")
|
||||
|
||||
try:
|
||||
profile_data = profile.get('data', {})
|
||||
url = profile_data.get('creator_url', [])[0] # Get first URL
|
||||
processed_ids = set(profile_data.get('processed_post_ids', []))
|
||||
creator_settings = profile_data.get('settings', {})
|
||||
|
||||
# Use common cookie settings from the UI
|
||||
use_cookie = self.use_cookie_checkbox.isChecked()
|
||||
cookie_text = self.cookie_text_input.text()
|
||||
cookie_file = self.selected_cookie_filepath
|
||||
|
||||
post_generator = download_from_api(
|
||||
api_url_input=url,
|
||||
logger=lambda msg: None, # Suppress logs
|
||||
cancellation_event=self.cancellation_event,
|
||||
pause_event=self.pause_event,
|
||||
use_cookie=use_cookie,
|
||||
cookie_text=cookie_text,
|
||||
selected_cookie_file=cookie_file,
|
||||
app_base_dir=self.app_base_dir,
|
||||
processed_post_ids=processed_ids,
|
||||
end_page=5
|
||||
)
|
||||
|
||||
for post_batch in post_generator:
|
||||
if self.cancellation_event.is_set(): break
|
||||
for post_data in post_batch:
|
||||
# Store the post AND the ENTIRE profile data
|
||||
master_new_post_list.append({
|
||||
'post_data': post_data,
|
||||
'profile_data': profile_data, # Pass the full profile
|
||||
'creator_name': profile_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
self.log_signal.emit(f"❌ Error checking {profile_name}: {e}")
|
||||
|
||||
# Emit the final aggregated list
|
||||
self.batch_update_check_complete_signal.emit(master_new_post_list)
|
||||
|
||||
def _batch_update_check_finished(self, all_new_posts_list):
|
||||
"""
|
||||
(GUI THREAD)
|
||||
Called when the batch update check is complete. Updates UI.
|
||||
"""
|
||||
self.download_thread = None # Clear the thread
|
||||
|
||||
if self.cancellation_event.is_set():
|
||||
self.log_signal.emit("ℹ️ Update check was cancelled.")
|
||||
self.reset_application_state() # Full reset
|
||||
return
|
||||
|
||||
if not all_new_posts_list:
|
||||
self.log_signal.emit("✅ All selected creators are up to date! No new posts found.")
|
||||
QMessageBox.information(self, "Up to Date", "No new posts were found for the selected creators.")
|
||||
self.reset_application_state() # Full reset
|
||||
return
|
||||
|
||||
total_posts = len(all_new_posts_list)
|
||||
|
||||
# --- MODIFIED BLOCK ---
|
||||
# Get the set of unique creator names who have new posts
|
||||
creators_with_new_posts = sorted(list(set(p['creator_name'] for p in all_new_posts_list)))
|
||||
total_creators = len(creators_with_new_posts)
|
||||
|
||||
self.log_signal.emit("=" * 40)
|
||||
|
||||
# Add the new line you requested
|
||||
if creators_with_new_posts:
|
||||
self.log_signal.emit(f"Creators With New Posts - {', '.join(creators_with_new_posts)}")
|
||||
|
||||
# Log the original summary line
|
||||
self.log_signal.emit(f"✅ Update check complete. Found {total_posts} new post(s) across {total_creators} creator(s).")
|
||||
# --- END OF MODIFIED BLOCK ---
|
||||
|
||||
self.log_signal.emit(" Click 'Start Download' to begin.")
|
||||
|
||||
self.fetched_posts_for_batch_update = all_new_posts_list
|
||||
self.is_ready_to_download_batch_update = True
|
||||
|
||||
self.progress_label.setText(f"Found {total_posts} new posts. Ready to download.")
|
||||
self.set_ui_enabled(True) # Re-enable UI
|
||||
self._update_button_states_and_connections() # Update buttons to "Start Download (X)"
|
||||
|
||||
def _start_download_of_batch_update(self):
|
||||
"""
|
||||
(GUI THREAD)
|
||||
Initiates the download of the posts found during the batch update check.
|
||||
|
||||
--- THIS IS THE CORRECTED ROBUST VERSION ---
|
||||
"""
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.log_signal.emit("=" * 40)
|
||||
self.log_signal.emit(f"🚀 Starting batch download for {len(self.fetched_posts_for_batch_update)} new post(s)...")
|
||||
|
||||
if self.main_log_output: self.main_log_output.clear()
|
||||
if self.external_log_output: self.external_log_output.clear()
|
||||
if self.missed_character_log_output: self.missed_character_log_output.clear()
|
||||
self.missed_key_terms_buffer.clear()
|
||||
self.already_logged_bold_key_terms.clear()
|
||||
|
||||
self.set_ui_enabled(False)
|
||||
|
||||
num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='PostWorker_')
|
||||
|
||||
self.total_posts_to_process = len(self.fetched_posts_for_batch_update)
|
||||
self.processed_posts_count = 0
|
||||
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
|
||||
|
||||
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
|
||||
|
||||
# 1. Define all LIVE RUNTIME arguments.
|
||||
# These are taken from the current app state and are the same for all workers.
|
||||
live_runtime_args = {
|
||||
'emitter': self.worker_to_gui_queue,
|
||||
'creator_name_cache': self.creator_name_cache,
|
||||
'known_names': list(KNOWN_NAMES),
|
||||
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
|
||||
'pause_event': self.pause_event,
|
||||
'cancellation_event': self.cancellation_event,
|
||||
'downloaded_files': self.downloaded_files,
|
||||
'downloaded_files_lock': self.downloaded_files_lock,
|
||||
'downloaded_file_hashes': self.downloaded_file_hashes,
|
||||
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
|
||||
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
|
||||
'num_file_threads': 1, # File threads per post worker
|
||||
'manga_date_file_counter_ref': None,
|
||||
'manga_global_file_counter_ref': None,
|
||||
'creator_download_folder_ignore_words': CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS,
|
||||
'downloaded_hash_counts': self.downloaded_hash_counts,
|
||||
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
|
||||
'skip_current_file_flag': None,
|
||||
'session_file_path': self.session_file_path,
|
||||
'session_lock': self.session_lock,
|
||||
'project_root_dir': self.app_base_dir,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
'start_offset': 0,
|
||||
'fetch_first': False,
|
||||
# Add live cookie settings
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'cookie_text': self.cookie_text_input.text(),
|
||||
'selected_cookie_file': self.selected_cookie_filepath,
|
||||
}
|
||||
|
||||
# 2. Define DEFAULTS for all settings that *should* be in the profile.
|
||||
# These will be used if the profile is old and missing a key.
|
||||
default_profile_settings = {
|
||||
'output_dir': self.dir_input.text().strip(), # Fallback to live UI
|
||||
'api_url': '',
|
||||
'character_filter_text': '',
|
||||
'skip_words_text': '',
|
||||
'remove_words_text': '',
|
||||
'custom_folder_name': None,
|
||||
'filter_mode': 'all',
|
||||
'text_only_scope': None,
|
||||
'text_export_format': 'txt',
|
||||
'single_pdf_mode': False,
|
||||
'skip_zip': False,
|
||||
'use_subfolders': False,
|
||||
'use_post_subfolders': False,
|
||||
'compress_images': False,
|
||||
'download_thumbnails': False,
|
||||
'skip_words_scope': SKIP_SCOPE_FILES,
|
||||
'char_filter_scope': CHAR_SCOPE_FILES,
|
||||
'show_external_links': False,
|
||||
'extract_links_only': False,
|
||||
'manga_mode_active': False,
|
||||
'manga_filename_style': STYLE_POST_TITLE,
|
||||
'allow_multipart_download': False,
|
||||
'manga_date_prefix': '',
|
||||
'scan_content_for_images': False,
|
||||
'use_date_prefix_for_subfolder': False,
|
||||
'date_prefix_format': "YYYY-MM-DD {post}",
|
||||
'keep_in_post_duplicates': False,
|
||||
'keep_duplicates_mode': DUPLICATE_HANDLING_HASH,
|
||||
'keep_duplicates_limit': 0,
|
||||
'multipart_scope': 'both',
|
||||
'multipart_parts_count': 4,
|
||||
'multipart_min_size_mb': 100,
|
||||
'manga_custom_filename_format': "{published} {title}",
|
||||
'manga_custom_date_format': "YYYY-MM-DD",
|
||||
'target_post_id_from_initial_url': None,
|
||||
'override_output_dir': None,
|
||||
'processed_post_ids': [],
|
||||
}
|
||||
|
||||
for item in self.fetched_posts_for_batch_update:
|
||||
post_data = item['post_data']
|
||||
|
||||
# --- THIS IS THE NEW, CORRECTED LOGIC ---
|
||||
full_profile_data = item.get('profile_data', {})
|
||||
saved_settings = full_profile_data.get('settings', {})
|
||||
# --- END OF NEW LOGIC ---
|
||||
|
||||
# 3. Construct the final arguments for this specific worker
|
||||
|
||||
# Start with a full set of defaults
|
||||
args_for_this_worker = default_profile_settings.copy()
|
||||
# Overwrite with any settings saved in the profile
|
||||
# This is where {"filter_mode": "video"} from Maplestar.json is applied
|
||||
args_for_this_worker.update(saved_settings)
|
||||
# Add all the live runtime arguments
|
||||
args_for_this_worker.update(live_runtime_args)
|
||||
|
||||
# 4. Manually parse values from the constructed args
|
||||
|
||||
# Set post-specific data
|
||||
args_for_this_worker['service'] = post_data.get('service')
|
||||
args_for_this_worker['user_id'] = post_data.get('user')
|
||||
|
||||
# Set download_root (which worker expects) from output_dir
|
||||
args_for_this_worker['download_root'] = args_for_this_worker.get('output_dir')
|
||||
|
||||
# Parse filters and commands
|
||||
raw_filters = args_for_this_worker.get('character_filter_text', '')
|
||||
parsed_filters, commands = self._parse_character_filters(raw_filters)
|
||||
args_for_this_worker['filter_character_list'] = parsed_filters
|
||||
args_for_this_worker['domain_override'] = commands.get('domain_override')
|
||||
args_for_this_worker['archive_only_mode'] = commands.get('archive_only', False)
|
||||
args_for_this_worker['sfp_threshold'] = commands.get('sfp_threshold')
|
||||
args_for_this_worker['handle_unknown_mode'] = commands.get('handle_unknown', False)
|
||||
|
||||
# Parse skip words and skip size
|
||||
skip_words_parts = [part.strip() for part in args_for_this_worker.get('skip_words_text', '').split(',') if part.strip()]
|
||||
args_for_this_worker['skip_file_size_mb'] = None
|
||||
args_for_this_worker['skip_words_list'] = []
|
||||
size_pattern = re.compile(r'\[(\d+)\]')
|
||||
for part in skip_words_parts:
|
||||
match = size_pattern.fullmatch(part)
|
||||
if match:
|
||||
args_for_this_worker['skip_file_size_mb'] = int(match.group(1))
|
||||
else:
|
||||
args_for_this_worker['skip_words_list'].append(part.lower())
|
||||
|
||||
# Parse remove_from_filename_words_list
|
||||
raw_remove_words = args_for_this_worker.get('remove_words_text', '')
|
||||
args_for_this_worker['remove_from_filename_words_list'] = [word.strip() for word in raw_remove_words.split(',') if word.strip()]
|
||||
|
||||
# Ensure processed_post_ids is a list (from the *original* profile data)
|
||||
args_for_this_worker['processed_post_ids'] = list(full_profile_data.get('processed_post_ids', []))
|
||||
|
||||
# Ensure api_url_input is set
|
||||
args_for_this_worker['api_url_input'] = args_for_this_worker.get('api_url', '')
|
||||
|
||||
self._submit_post_to_worker_pool(
|
||||
post_data,
|
||||
args_for_this_worker,
|
||||
1, # File threads per worker (1 for sequential batch)
|
||||
self.worker_to_gui_queue,
|
||||
ppw_expected_keys,
|
||||
{}
|
||||
)
|
||||
|
||||
self.fetched_posts_for_batch_update = []
|
||||
self.is_fetcher_thread_running = False
|
||||
self._check_if_all_work_is_done()
|
||||
|
||||
def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
|
||||
"""Helper to prepare and submit a single post processing task to the thread pool."""
|
||||
global PostProcessorWorker
|
||||
@@ -5176,56 +5448,76 @@ class DownloaderApp (QWidget ):
|
||||
self ._filter_links_log ()
|
||||
|
||||
def cancel_download_button_action(self):
|
||||
"""
|
||||
Handles the user clicking the 'Cancel' button.
|
||||
This version forcefully shuts down thread pools.
|
||||
"""
|
||||
if not self._is_download_active() and not self.is_paused:
|
||||
self.log_signal.emit("ℹ️ Cancel button clicked, but no download is active.")
|
||||
return
|
||||
|
||||
if self.is_paused:
|
||||
self.log_signal.emit("❌ Cancellation requested while paused. Stopping all workers...")
|
||||
|
||||
if self._is_download_active() and hasattr(self.download_thread, 'cancel'):
|
||||
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
|
||||
self.download_thread.cancel()
|
||||
else:
|
||||
# Fallback for other download types
|
||||
self.cancellation_event.set()
|
||||
|
||||
# Update UI to "Cancelling" state
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
|
||||
if hasattr(self, 'reset_button'):
|
||||
self.reset_button.setEnabled(False)
|
||||
self.log_signal.emit("❌ Cancellation requested by user. Stopping all workers...")
|
||||
|
||||
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
|
||||
self.pause_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
if hasattr(self, 'reset_button'):
|
||||
self.reset_button.setEnabled(False)
|
||||
|
||||
# 1. Set the master cancellation event
|
||||
# This tells all workers to stop *cooperatively*
|
||||
if not self.cancellation_event.is_set():
|
||||
self.cancellation_event.set()
|
||||
|
||||
# Only call QThread-specific methods if the thread is a QThread
|
||||
# 2. Forcefully shut down QThreads
|
||||
if self.download_thread and hasattr(self.download_thread, 'requestInterruption'):
|
||||
self.download_thread.requestInterruption()
|
||||
self.log_signal.emit(" Signaled single download thread to interrupt.")
|
||||
|
||||
if self.thread_pool:
|
||||
self.log_signal.emit(" Signaling worker pool to cancel futures...")
|
||||
|
||||
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
|
||||
self.log_signal.emit(" Cancelling active External Link download thread...")
|
||||
self.external_link_download_thread.cancel()
|
||||
|
||||
# ... (add any other QThread .cancel() calls here if you have them) ...
|
||||
if isinstance(self.download_thread, NhentaiDownloadThread):
|
||||
self.log_signal.emit(" Signaling nhentai download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, BunkrDownloadThread):
|
||||
self.log_signal.emit(" Signaling Bunkr download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, Saint2DownloadThread):
|
||||
self.log_signal.emit(" Signaling Saint2 download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, EromeDownloadThread):
|
||||
self.log_signal.emit(" Signaling Erome download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
if isinstance(self.download_thread, Hentai2readDownloadThread):
|
||||
self.log_signal.emit(" Signaling Hentai2Read download thread to cancel.")
|
||||
self.download_thread.cancel()
|
||||
|
||||
# 3. Forcefully shut down ThreadPoolExecutors
|
||||
# This is the critical fix for batch/update downloads
|
||||
if self.thread_pool:
|
||||
self.log_signal.emit(" Signaling worker pool to shut down...")
|
||||
# We use cancel_futures=True to actively stop pending tasks
|
||||
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
self.thread_pool = None
|
||||
self.active_futures = []
|
||||
self.log_signal.emit(" Worker pool shutdown initiated.")
|
||||
|
||||
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
|
||||
self.log_signal.emit(" Signaling retry worker pool to shut down...")
|
||||
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
self.retry_thread_pool = None
|
||||
self.active_retry_futures = []
|
||||
self.log_signal.emit(" Retry pool shutdown initiated.")
|
||||
|
||||
# 4. Manually trigger the 'finished' logic to reset the UI
|
||||
# This is safe because we just shut down all the threads
|
||||
self.download_finished(0, 0, True, [])
|
||||
|
||||
def _get_domain_for_service(self, service_name: str) -> str:
|
||||
"""Determines the base domain for a given service."""
|
||||
@@ -5268,6 +5560,8 @@ class DownloaderApp (QWidget ):
|
||||
self.download_thread.deleteLater()
|
||||
self.download_thread = None # This is the crucial line
|
||||
self.is_finishing = False
|
||||
|
||||
self.finish_lock.release()
|
||||
self._process_next_favorite_download()
|
||||
return
|
||||
|
||||
@@ -5547,6 +5841,7 @@ class DownloaderApp (QWidget ):
|
||||
'known_names':list (KNOWN_NAMES ),
|
||||
'emitter':self .worker_to_gui_queue ,
|
||||
'unwanted_keywords':{'spicy','hd','nsfw','4k','preview','teaser','clip'},
|
||||
'creator_name_cache': self.creator_name_cache,
|
||||
'domain_override': domain_override_command,
|
||||
'sfp_threshold': sfp_threshold_command,
|
||||
'handle_unknown_mode': handle_unknown_command,
|
||||
@@ -5573,7 +5868,14 @@ class DownloaderApp (QWidget ):
|
||||
'target_post_id_from_initial_url':None ,
|
||||
'custom_folder_name':None ,
|
||||
'num_file_threads':1 ,
|
||||
|
||||
|
||||
# --- START: ADDED COOKIE FIX ---
|
||||
'use_cookie': self.use_cookie_checkbox.isChecked(),
|
||||
'cookie_text': self.cookie_text_input.text(),
|
||||
'selected_cookie_file': self.selected_cookie_filepath,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
# --- END: ADDED COOKIE FIX ---
|
||||
|
||||
'manga_date_file_counter_ref':None ,
|
||||
}
|
||||
|
||||
@@ -5619,13 +5921,11 @@ class DownloaderApp (QWidget ):
|
||||
api_domain = parsed_api_url.netloc if parsed_api_url.netloc else self._get_domain_for_service(service)
|
||||
post_page_url = f"https://{api_domain}/{service}/user/{user_id}/post/{post_id}"
|
||||
|
||||
# --- NEW LOGIC: Differentiate between loaded files and live session errors ---
|
||||
# Initialize variables before the conditional blocks
|
||||
target_folder_path_for_download = None
|
||||
filename_override_for_download = None
|
||||
|
||||
if job_details.get('is_loaded_from_txt'):
|
||||
# --- BEHAVIOR FOR LOADED FILES: Recalculate everything from current UI settings ---
|
||||
self.log_signal.emit(f" Retrying loaded file. Recalculating path and name from current UI settings...")
|
||||
|
||||
# 1. Get all current settings and job data
|
||||
@@ -5922,6 +6222,8 @@ class DownloaderApp (QWidget ):
|
||||
self.is_fetching_only = False
|
||||
self.fetched_posts_for_download = []
|
||||
self.is_ready_to_download_fetched = False
|
||||
self.fetched_posts_for_batch_update = []
|
||||
self.is_ready_to_download_batch_update = False
|
||||
self.allcomic_warning_shown = False
|
||||
|
||||
self.set_ui_enabled(True)
|
||||
@@ -6217,7 +6519,7 @@ class DownloaderApp (QWidget ):
|
||||
'manga_date_prefix': self.manga_date_prefix_input.text().strip(),
|
||||
'manga_date_file_counter_ref': None,
|
||||
'scan_content_for_images': self.scan_content_images_checkbox.isChecked(),
|
||||
|
||||
'creator_name_cache': self.creator_name_cache,
|
||||
'creator_download_folder_ignore_words': creator_folder_ignore_words_for_run,
|
||||
'num_file_threads_for_worker': effective_num_file_threads_per_worker,
|
||||
'multipart_scope': 'files',
|
||||
@@ -6272,23 +6574,18 @@ class DownloaderApp (QWidget ):
|
||||
self._tr("restore_pending_message_creator_selection",
|
||||
"Please 'Restore Download' or 'Discard Session' before selecting new creators."))
|
||||
return
|
||||
dialog = EmptyPopupDialog(self.app_base_dir, self)
|
||||
dialog = EmptyPopupDialog(self.user_data_path, self)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
if dialog.update_profile_data:
|
||||
self.active_update_profile = dialog.update_profile_data
|
||||
self.link_input.setText(dialog.update_creator_name)
|
||||
self.favorite_download_queue.clear()
|
||||
|
||||
if 'settings' in self.active_update_profile:
|
||||
self.log_signal.emit(f"ℹ️ Applying saved settings from '{dialog.update_creator_name}' profile...")
|
||||
self._load_ui_from_settings_dict(self.active_update_profile['settings'])
|
||||
self.log_signal.emit(" Settings restored.")
|
||||
# --- NEW BATCH UPDATE LOGIC ---
|
||||
if hasattr(dialog, 'update_profiles_list') and dialog.update_profiles_list:
|
||||
self.active_update_profiles_list = dialog.update_profiles_list
|
||||
self.log_signal.emit(f"ℹ️ Loaded {len(self.active_update_profiles_list)} creator profile(s). Checking for updates...")
|
||||
self.link_input.setText(f"{len(self.active_update_profiles_list)} profiles loaded for update check...")
|
||||
self._start_batch_update_check(self.active_update_profiles_list)
|
||||
|
||||
self.log_signal.emit(f"ℹ️ Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
|
||||
self._update_button_states_and_connections()
|
||||
|
||||
# --- Original logic for adding creators to queue ---
|
||||
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
|
||||
self.active_update_profile = None
|
||||
self.active_update_profile = None # Ensure single update mode is off
|
||||
self.favorite_download_queue.clear()
|
||||
|
||||
for creator_data in dialog.selected_creators_for_queue:
|
||||
@@ -6318,11 +6615,7 @@ class DownloaderApp (QWidget ):
|
||||
if hasattr(self, 'link_input'):
|
||||
self.last_link_input_text_for_queue_sync = self.link_input.text()
|
||||
|
||||
# --- START: MODIFIED LOGIC ---
|
||||
# Manually trigger the UI update now that the queue is populated and the dialog is closed.
|
||||
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
|
||||
# --- END: MODIFIED LOGIC ---
|
||||
|
||||
def _load_saved_cookie_settings(self):
|
||||
"""Loads and applies saved cookie settings on startup."""
|
||||
try:
|
||||
@@ -6483,26 +6776,6 @@ class DownloaderApp (QWidget ):
|
||||
char_filter_is_empty = not self.character_input.text().strip()
|
||||
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
|
||||
|
||||
if manga_mode_is_checked and char_filter_is_empty and not extract_links_only:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setWindowTitle("Renaming Mode Filter Warning")
|
||||
msg_box.setText(
|
||||
"Renaming Mode is enabled, but 'Filter by Character(s)' is empty.\n\n"
|
||||
"This is a one-time warning for this entire batch of downloads.\n\n"
|
||||
"Proceeding without a filter may result in generic filenames and folders.\n\n"
|
||||
"Proceed with the entire batch?"
|
||||
)
|
||||
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
|
||||
cancel_button = msg_box.addButton("Cancel Entire Batch", QMessageBox.RejectRole)
|
||||
msg_box.exec_()
|
||||
if msg_box.clickedButton() == cancel_button:
|
||||
self.log_signal.emit("❌ Entire favorite queue cancelled by user at Renaming Mode warning.")
|
||||
self.favorite_download_queue.clear()
|
||||
self.is_processing_favorites_queue = False
|
||||
self.set_ui_enabled(True)
|
||||
return # Stop processing the queue
|
||||
|
||||
if self ._is_download_active ():
|
||||
self .log_signal .emit ("ℹ️ Waiting for current download to finish before starting next favorite.")
|
||||
return
|
||||
|
||||
@@ -26,6 +26,16 @@ KNOWN_TXT_MATCH_CLEANUP_PATTERNS = [
|
||||
r'\bPreview\b',
|
||||
]
|
||||
|
||||
# --- START NEW CODE ---
|
||||
# Regular expression to detect CJK characters
|
||||
# Covers Hiragana, Katakana, Half/Full width forms, CJK Unified Ideographs, Hangul Syllables, etc.
|
||||
cjk_pattern = re.compile(r'[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uffef\u4e00-\u9fff\uac00-\ud7af]')
|
||||
|
||||
def contains_cjk(text):
|
||||
"""Checks if the text contains any CJK characters."""
|
||||
return bool(cjk_pattern.search(text))
|
||||
# --- END NEW CODE ---
|
||||
|
||||
# --- Text Matching and Manipulation Utilities ---
|
||||
|
||||
def is_title_match_for_character(post_title, character_name_filter):
|
||||
@@ -42,7 +52,7 @@ def is_title_match_for_character(post_title, character_name_filter):
|
||||
"""
|
||||
if not post_title or not character_name_filter:
|
||||
return False
|
||||
|
||||
|
||||
# Use word boundaries (\b) to match whole words only
|
||||
pattern = r"(?i)\b" + re.escape(str(character_name_filter).strip()) + r"\b"
|
||||
return bool(re.search(pattern, post_title))
|
||||
@@ -62,7 +72,7 @@ def is_filename_match_for_character(filename, character_name_filter):
|
||||
"""
|
||||
if not filename or not character_name_filter:
|
||||
return False
|
||||
|
||||
|
||||
return str(character_name_filter).strip().lower() in filename.lower()
|
||||
|
||||
|
||||
@@ -101,16 +111,16 @@ def extract_folder_name_from_title(title, unwanted_keywords):
|
||||
"""
|
||||
if not title:
|
||||
return 'Uncategorized'
|
||||
|
||||
|
||||
title_lower = title.lower()
|
||||
# Find all whole words in the title
|
||||
tokens = re.findall(r'\b[\w\-]+\b', title_lower)
|
||||
|
||||
|
||||
for token in tokens:
|
||||
clean_token = clean_folder_name(token)
|
||||
if clean_token and clean_token.lower() not in unwanted_keywords:
|
||||
return clean_token
|
||||
|
||||
|
||||
# Fallback to cleaning the full title if no single significant word is found
|
||||
cleaned_full_title = clean_folder_name(title)
|
||||
return cleaned_full_title if cleaned_full_title else 'Uncategorized'
|
||||
@@ -120,6 +130,7 @@ def match_folders_from_title(title, names_to_match, unwanted_keywords):
|
||||
"""
|
||||
Matches folder names from a title based on a list of known name objects.
|
||||
Each name object is a dict: {'name': 'PrimaryName', 'aliases': ['alias1', ...]}
|
||||
MODIFIED: Uses substring matching for CJK aliases, word boundary for others.
|
||||
|
||||
Args:
|
||||
title (str): The post title to check.
|
||||
@@ -137,10 +148,11 @@ def match_folders_from_title(title, names_to_match, unwanted_keywords):
|
||||
for pat_str in KNOWN_TXT_MATCH_CLEANUP_PATTERNS:
|
||||
cleaned_title = re.sub(pat_str, ' ', cleaned_title, flags=re.IGNORECASE)
|
||||
cleaned_title = re.sub(r'\s+', ' ', cleaned_title).strip()
|
||||
# Store both original case cleaned title and lower case for different matching
|
||||
title_lower = cleaned_title.lower()
|
||||
|
||||
matched_cleaned_names = set()
|
||||
|
||||
|
||||
# Sort by name length descending to match longer names first (e.g., "Cloud Strife" before "Cloud")
|
||||
sorted_name_objects = sorted(names_to_match, key=lambda x: len(x.get("name", "")), reverse=True)
|
||||
|
||||
@@ -149,19 +161,43 @@ def match_folders_from_title(title, names_to_match, unwanted_keywords):
|
||||
aliases = name_obj.get("aliases", [])
|
||||
if not primary_folder_name or not aliases:
|
||||
continue
|
||||
|
||||
|
||||
# <<< START MODIFICATION >>>
|
||||
cleaned_primary_name = clean_folder_name(primary_folder_name)
|
||||
if not cleaned_primary_name or cleaned_primary_name.lower() in unwanted_keywords:
|
||||
continue # Skip this entry entirely if its primary name is unwanted or empty
|
||||
|
||||
match_found_for_this_object = False
|
||||
for alias in aliases:
|
||||
if not alias: continue
|
||||
alias_lower = alias.lower()
|
||||
if not alias_lower: continue
|
||||
|
||||
# Use word boundaries for accurate matching
|
||||
pattern = r'\b' + re.escape(alias_lower) + r'\b'
|
||||
if re.search(pattern, title_lower):
|
||||
cleaned_primary_name = clean_folder_name(primary_folder_name)
|
||||
if cleaned_primary_name.lower() not in unwanted_keywords:
|
||||
|
||||
# Check if the alias contains CJK characters
|
||||
if contains_cjk(alias):
|
||||
# Use simple substring matching for CJK
|
||||
if alias_lower in title_lower:
|
||||
matched_cleaned_names.add(cleaned_primary_name)
|
||||
break # Move to the next name object once a match is found for this one
|
||||
|
||||
match_found_for_this_object = True
|
||||
break # Move to the next name object
|
||||
else:
|
||||
# Use original word boundary matching for non-CJK
|
||||
try:
|
||||
# Compile pattern for efficiency if used repeatedly, though here it changes each loop
|
||||
pattern = r'\b' + re.escape(alias_lower) + r'\b'
|
||||
if re.search(pattern, title_lower):
|
||||
matched_cleaned_names.add(cleaned_primary_name)
|
||||
match_found_for_this_object = True
|
||||
break # Move to the next name object
|
||||
except re.error as e:
|
||||
# Log error if the alias creates an invalid regex (unlikely with escape)
|
||||
print(f"Regex error for alias '{alias}': {e}") # Or use proper logging
|
||||
continue
|
||||
|
||||
# This outer break logic remains the same (though slightly redundant with inner breaks)
|
||||
if match_found_for_this_object:
|
||||
pass # Already added and broke inner loop
|
||||
# <<< END MODIFICATION >>>
|
||||
|
||||
return sorted(list(matched_cleaned_names))
|
||||
|
||||
|
||||
@@ -169,6 +205,8 @@ def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keyw
|
||||
"""
|
||||
Matches folder names from a filename, prioritizing longer and more specific aliases.
|
||||
It returns immediately after finding the first (longest) match.
|
||||
MODIFIED: Prioritizes boundary-aware matches for Latin characters,
|
||||
falls back to substring search for CJK compatibility.
|
||||
|
||||
Args:
|
||||
filename (str): The filename to check.
|
||||
@@ -188,23 +226,49 @@ def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keyw
|
||||
for name_obj in names_to_match:
|
||||
primary_name = name_obj.get("name")
|
||||
if not primary_name: continue
|
||||
|
||||
|
||||
cleaned_primary_name = clean_folder_name(primary_name)
|
||||
if not cleaned_primary_name or cleaned_primary_name.lower() in unwanted_keywords:
|
||||
continue
|
||||
|
||||
for alias in name_obj.get("aliases", []):
|
||||
if alias.lower():
|
||||
alias_map_to_primary.append((alias.lower(), cleaned_primary_name))
|
||||
|
||||
if alias: # Check if alias is not None and not an empty string
|
||||
alias_lower_val = alias.lower()
|
||||
if alias_lower_val: # Check again after lowercasing
|
||||
alias_map_to_primary.append((alias_lower_val, cleaned_primary_name))
|
||||
|
||||
# Sort by alias length, descending, to match longer aliases first
|
||||
alias_map_to_primary.sort(key=lambda x: len(x[0]), reverse=True)
|
||||
|
||||
# <<< MODIFICATION: Return the FIRST match found, which will be the longest >>>
|
||||
# Return the FIRST match found, which will be the longest
|
||||
for alias_lower, primary_name_for_alias in alias_map_to_primary:
|
||||
if alias_lower in filename_lower:
|
||||
# Found the longest possible alias that is a substring. Return immediately.
|
||||
return [primary_name_for_alias]
|
||||
try:
|
||||
# 1. Attempt boundary-aware match first (good for English/Latin)
|
||||
# Matches alias if it's at the start/end or surrounded by common separators
|
||||
# We use word boundaries (\b) and also check for common non-word separators like +_-
|
||||
pattern = r'(?:^|[\s_+-])' + re.escape(alias_lower) + r'(?:[\s_+-]|$)'
|
||||
|
||||
if re.search(pattern, filename_lower):
|
||||
# Found a precise, boundary-aware match. This is the best case.
|
||||
return [primary_name_for_alias]
|
||||
|
||||
# 2. Fallback: Simple substring check (for CJK or other cases)
|
||||
# This executes ONLY if the boundary match above failed.
|
||||
# We check if the alias contains CJK OR if the filename does.
|
||||
# This avoids applying the simple 'in' check for Latin-only aliases in Latin-only filenames.
|
||||
elif (contains_cjk(alias_lower) or contains_cjk(filename_lower)) and alias_lower in filename_lower:
|
||||
# This is the fallback for CJK compatibility.
|
||||
return [primary_name_for_alias]
|
||||
|
||||
# If alias is "ul" and filename is "sin+título":
|
||||
# 1. re.search(r'(?:^|[\s_+-])ul(?:[\s_+-]|$)', "sin+título") -> Fails (good)
|
||||
# 2. contains_cjk("ul") -> False
|
||||
# 3. contains_cjk("sin+título") -> False
|
||||
# 4. No match is found for "ul". (correct)
|
||||
|
||||
except re.error as e:
|
||||
print(f"Regex error matching alias '{alias_lower}' in filename '{filename_lower}': {e}")
|
||||
continue # Skip this alias if regex fails
|
||||
|
||||
# If the loop finishes without any matches, return an empty list.
|
||||
return []
|
||||
return []
|
||||
111
structure.txt
Normal file
111
structure.txt
Normal file
@@ -0,0 +1,111 @@
|
||||
├── assets/
|
||||
│ ├── Kemono.ico
|
||||
│ ├── Kemono.png
|
||||
│ ├── Ko-fi.png
|
||||
│ ├── buymeacoffee.png
|
||||
│ ├── discord.png
|
||||
│ ├── github.png
|
||||
│ ├── instagram.png
|
||||
│ └── patreon.png
|
||||
├── data/
|
||||
│ ├── creators.json
|
||||
│ └── dejavu-sans/
|
||||
│ ├── DejaVu Fonts License.txt
|
||||
│ ├── DejaVuSans-Bold.ttf
|
||||
│ ├── DejaVuSans-BoldOblique.ttf
|
||||
│ ├── DejaVuSans-ExtraLight.ttf
|
||||
│ ├── DejaVuSans-Oblique.ttf
|
||||
│ ├── DejaVuSans.ttf
|
||||
│ ├── DejaVuSansCondensed-Bold.ttf
|
||||
│ ├── DejaVuSansCondensed-BoldOblique.ttf
|
||||
│ ├── DejaVuSansCondensed-Oblique.ttf
|
||||
│ └── DejaVuSansCondensed.ttf
|
||||
├── directory_tree.txt
|
||||
├── main.py
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── config/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── constants.py
|
||||
│ ├── core/
|
||||
│ │ ├── Hentai2read_client.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── allcomic_client.py
|
||||
│ │ ├── api_client.py
|
||||
│ │ ├── booru_client.py
|
||||
│ │ ├── bunkr_client.py
|
||||
│ │ ├── discord_client.py
|
||||
│ │ ├── erome_client.py
|
||||
│ │ ├── fap_nation_client.py
|
||||
│ │ ├── manager.py
|
||||
│ │ ├── mangadex_client.py
|
||||
│ │ ├── nhentai_client.py
|
||||
│ │ ├── pixeldrain_client.py
|
||||
│ │ ├── rule34video_client.py
|
||||
│ │ ├── saint2_client.py
|
||||
│ │ ├── simpcity_client.py
|
||||
│ │ ├── toonily_client.py
|
||||
│ │ └── workers.py
|
||||
│ ├── i18n/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── translator.py
|
||||
│ ├── services/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── drive_downloader.py
|
||||
│ │ ├── multipart_downloader.py
|
||||
│ │ └── updater.py
|
||||
│ ├── ui/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── assets.py
|
||||
│ │ ├── classes/
|
||||
│ │ │ ├── allcomic_downloader_thread.py
|
||||
│ │ │ ├── booru_downloader_thread.py
|
||||
│ │ │ ├── bunkr_downloader_thread.py
|
||||
│ │ │ ├── discord_downloader_thread.py
|
||||
│ │ │ ├── downloader_factory.py
|
||||
│ │ │ ├── drive_downloader_thread.py
|
||||
│ │ │ ├── erome_downloader_thread.py
|
||||
│ │ │ ├── external_link_downloader_thread.py
|
||||
│ │ │ ├── fap_nation_downloader_thread.py
|
||||
│ │ │ ├── hentai2read_downloader_thread.py
|
||||
│ │ │ ├── kemono_discord_downloader_thread.py
|
||||
│ │ │ ├── mangadex_downloader_thread.py
|
||||
│ │ │ ├── nhentai_downloader_thread.py
|
||||
│ │ │ ├── pixeldrain_downloader_thread.py
|
||||
│ │ │ ├── rule34video_downloader_thread.py
|
||||
│ │ │ ├── saint2_downloader_thread.py
|
||||
│ │ │ ├── simp_city_downloader_thread.py
|
||||
│ │ │ └── toonily_downloader_thread.py
|
||||
│ │ ├── dialogs/
|
||||
│ │ │ ├── ConfirmAddAllDialog.py
|
||||
│ │ │ ├── CookieHelpDialog.py
|
||||
│ │ │ ├── CustomFilenameDialog.py
|
||||
│ │ │ ├── DownloadExtractedLinksDialog.py
|
||||
│ │ │ ├── DownloadHistoryDialog.py
|
||||
│ │ │ ├── EmptyPopupDialog.py
|
||||
│ │ │ ├── ErrorFilesDialog.py
|
||||
│ │ │ ├── ExportLinksDialog.py
|
||||
│ │ │ ├── ExportOptionsDialog.py
|
||||
│ │ │ ├── FavoriteArtistsDialog.py
|
||||
│ │ │ ├── FavoritePostsDialog.py
|
||||
│ │ │ ├── FutureSettingsDialog.py
|
||||
│ │ │ ├── HelpGuideDialog.py
|
||||
│ │ │ ├── KeepDuplicatesDialog.py
|
||||
│ │ │ ├── KnownNamesFilterDialog.py
|
||||
│ │ │ ├── MoreOptionsDialog.py
|
||||
│ │ │ ├── MultipartScopeDialog.py
|
||||
│ │ │ ├── SinglePDF.py
|
||||
│ │ │ ├── SupportDialog.py
|
||||
│ │ │ ├── TourDialog.py
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ └── discord_pdf_generator.py
|
||||
│ │ └── main_window.py
|
||||
│ └── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── command.py
|
||||
│ ├── file_utils.py
|
||||
│ ├── network_utils.py
|
||||
│ ├── resolution.py
|
||||
│ └── text_utils.py
|
||||
├── structure.txt
|
||||
└── yt-dlp.exe
|
||||
Reference in New Issue
Block a user