This commit is contained in:
Yuvi9587
2025-07-27 06:32:15 -07:00
parent 9db89cfad0
commit e3dd0e70b6
9 changed files with 508 additions and 254 deletions

View File

@@ -120,7 +120,7 @@ def download_from_api(
selected_cookie_file=None, selected_cookie_file=None,
app_base_dir=None, app_base_dir=None,
manga_filename_style_for_sort_check=None, manga_filename_style_for_sort_check=None,
processed_post_ids=None # --- ADD THIS ARGUMENT --- processed_post_ids=None
): ):
headers = { headers = {
'User-Agent': 'Mozilla/5.0', 'User-Agent': 'Mozilla/5.0',
@@ -139,9 +139,19 @@ def download_from_api(
parsed_input_url_for_domain = urlparse(api_url_input) parsed_input_url_for_domain = urlparse(api_url_input)
api_domain = parsed_input_url_for_domain.netloc api_domain = parsed_input_url_for_domain.netloc
if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']): fallback_api_domain = None
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
api_domain = "kemono.su" # --- START: MODIFIED DOMAIN LOGIC WITH FALLBACK ---
if 'kemono.cr' in api_domain.lower():
fallback_api_domain = 'kemono.su'
elif 'coomer.st' in api_domain.lower():
fallback_api_domain = 'coomer.su'
elif 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}'. Defaulting to kemono.cr with fallback to kemono.su.")
api_domain = "kemono.cr"
fallback_api_domain = "kemono.su"
# --- END: MODIFIED DOMAIN LOGIC WITH FALLBACK ---
cookies_for_api = None cookies_for_api = None
if use_cookie and app_base_dir: if use_cookie and app_base_dir:
cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain) cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
@@ -178,7 +188,6 @@ def download_from_api(
logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).") logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
page_size = 50 page_size = 50
if is_manga_mode_fetch_all_and_sort_oldest_first: if is_manga_mode_fetch_all_and_sort_oldest_first:
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...") logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
@@ -191,6 +200,12 @@ def download_from_api(
logger(f" Manga Mode: Starting fetch from page 1 (offset 0).") logger(f" Manga Mode: Starting fetch from page 1 (offset 0).")
if end_page: if end_page:
logger(f" Manga Mode: Will fetch up to page {end_page}.") logger(f" Manga Mode: Will fetch up to page {end_page}.")
# --- START: MANGA MODE FALLBACK LOGIC ---
is_first_page_attempt_manga = True
api_base_url_manga = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
# --- END: MANGA MODE FALLBACK LOGIC ---
while True: while True:
if pause_event and pause_event.is_set(): if pause_event and pause_event.is_set():
logger(" Manga mode post fetching paused...") logger(" Manga mode post fetching paused...")
@@ -208,7 +223,10 @@ def download_from_api(
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.") logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break break
try: try:
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) # --- START: MANGA MODE FALLBACK EXECUTION ---
posts_batch_manga = fetch_posts_paginated(api_base_url_manga, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
is_first_page_attempt_manga = False # Success, no need to fallback
# --- END: MANGA MODE FALLBACK EXECUTION ---
if not isinstance(posts_batch_manga, list): if not isinstance(posts_batch_manga, list):
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.") logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break break
@@ -220,9 +238,21 @@ def download_from_api(
logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).") logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
break break
all_posts_for_manga_mode.extend(posts_batch_manga) all_posts_for_manga_mode.extend(posts_batch_manga)
logger(f"MANGA_FETCH_PROGRESS:{len(all_posts_for_manga_mode)}:{current_page_num_manga}")
current_offset_manga += page_size current_offset_manga += page_size
time.sleep(0.6) time.sleep(0.6)
except RuntimeError as e: except RuntimeError as e:
# --- START: MANGA MODE FALLBACK HANDLING ---
if is_first_page_attempt_manga and fallback_api_domain:
logger(f" ⚠️ Initial API fetch (Manga Mode) from '{api_domain}' failed: {e}")
logger(f" ↪️ Falling back to old domain: '{fallback_api_domain}'")
api_domain = fallback_api_domain
api_base_url_manga = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
is_first_page_attempt_manga = False
continue # Retry the same offset with the new domain
# --- END: MANGA MODE FALLBACK HANDLING ---
if "cancelled by user" in str(e).lower(): if "cancelled by user" in str(e).lower():
logger(f" Manga mode pagination stopped due to cancellation: {e}") logger(f" Manga mode pagination stopped due to cancellation: {e}")
else: else:
@@ -232,7 +262,12 @@ def download_from_api(
logger(f"❌ Unexpected error during manga mode fetch: {e}") logger(f"❌ Unexpected error during manga mode fetch: {e}")
traceback.print_exc() traceback.print_exc()
break break
if cancellation_event and cancellation_event.is_set(): return if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode:
logger(f"MANGA_FETCH_COMPLETE:{len(all_posts_for_manga_mode)}")
if all_posts_for_manga_mode: if all_posts_for_manga_mode:
if processed_post_ids: if processed_post_ids:
original_count = len(all_posts_for_manga_mode) original_count = len(all_posts_for_manga_mode)
@@ -278,6 +313,12 @@ def download_from_api(
current_offset = (start_page - 1) * page_size current_offset = (start_page - 1) * page_size
current_page_num = start_page current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).") logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
# --- START: STANDARD PAGINATION FALLBACK LOGIC ---
is_first_page_attempt = True
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
# --- END: STANDARD PAGINATION FALLBACK LOGIC ---
while True: while True:
if pause_event and pause_event.is_set(): if pause_event and pause_event.is_set():
logger(" Post fetching loop paused...") logger(" Post fetching loop paused...")
@@ -296,11 +337,23 @@ def download_from_api(
logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.") logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
break break
try: try:
# --- START: STANDARD PAGINATION FALLBACK EXECUTION ---
posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api) posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
is_first_page_attempt = False # Success, no more fallbacks needed
# --- END: STANDARD PAGINATION FALLBACK EXECUTION ---
if not isinstance(posts_batch, list): 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}).") logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
break break
except RuntimeError as e: except RuntimeError as e:
# --- START: STANDARD PAGINATION FALLBACK HANDLING ---
if is_first_page_attempt and fallback_api_domain:
logger(f" ⚠️ Initial API fetch from '{api_domain}' failed: {e}")
logger(f" ↪️ Falling back to old domain: '{fallback_api_domain}'")
api_domain = fallback_api_domain
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
is_first_page_attempt = False
continue # Retry the same offset with the new domain
# --- END: STANDARD PAGINATION FALLBACK HANDLING ---
if "cancelled by user" in str(e).lower(): if "cancelled by user" in str(e).lower():
logger(f" Pagination stopped due to cancellation: {e}") logger(f" Pagination stopped due to cancellation: {e}")
else: else:

View File

@@ -5,11 +5,10 @@ import json
import traceback import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future from concurrent.futures import ThreadPoolExecutor, as_completed, Future
from .api_client import download_from_api from .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread from .workers import PostProcessorWorker
from ..config.constants import ( from ..config.constants import (
STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING, STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING,
MAX_THREADS, POST_WORKER_BATCH_THRESHOLD, POST_WORKER_NUM_BATCHES, MAX_THREADS
POST_WORKER_BATCH_DELAY_SECONDS
) )
from ..utils.file_utils import clean_folder_name from ..utils.file_utils import clean_folder_name
@@ -44,6 +43,7 @@ class DownloadManager:
self.creator_profiles_dir = None self.creator_profiles_dir = None
self.current_creator_name_for_profile = None self.current_creator_name_for_profile = None
self.current_creator_profile_path = None self.current_creator_profile_path = None
self.session_file_path = None
def _log(self, message): def _log(self, message):
"""Puts a progress message into the queue for the UI.""" """Puts a progress message into the queue for the UI."""
@@ -62,11 +62,15 @@ class DownloadManager:
self._log("❌ Cannot start a new session: A session is already in progress.") self._log("❌ Cannot start a new session: A session is already in progress.")
return return
self.session_file_path = config.get('session_file_path')
creator_profile_data = self._setup_creator_profile(config) creator_profile_data = self._setup_creator_profile(config)
creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', []) # Save settings to profile at the start of the session
self._save_creator_profile(creator_profile_data) if self.current_creator_profile_path:
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.") creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_profile_data)
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
self.is_running = True self.is_running = True
self.cancellation_event.clear() self.cancellation_event.clear()
@@ -77,6 +81,7 @@ class DownloadManager:
self.total_downloads = 0 self.total_downloads = 0
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
is_single_post = bool(config.get('target_post_id_from_initial_url')) is_single_post = bool(config.get('target_post_id_from_initial_url'))
use_multithreading = config.get('use_multithreading', True) use_multithreading = config.get('use_multithreading', True)
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING] is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
@@ -86,71 +91,98 @@ class DownloadManager:
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
fetcher_thread = threading.Thread( fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool, target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data, creator_profile_data), # Add argument here args=(config, restore_data, creator_profile_data),
daemon=True daemon=True
) )
fetcher_thread.start() fetcher_thread.start()
else: else:
self._start_single_threaded_session(config) # Single-threaded mode does not use the manager's complex logic
self._log(" Manager is handing off to a single-threaded worker...")
# The single-threaded worker will manage its own lifecycle and signals.
# The manager's role for this session is effectively over.
self.is_running = False # Allow another session to start if needed
self.progress_queue.put({'type': 'handoff_to_single_thread', 'payload': (config,)})
def _start_single_threaded_session(self, config):
"""Handles downloads that are best processed by a single worker thread."""
self._log(" Initializing single-threaded download process...")
self.worker_thread = threading.Thread(
target=self._run_single_worker,
args=(config,),
daemon=True
)
self.worker_thread.start()
def _run_single_worker(self, config): def _fetch_and_queue_posts_for_pool(self, config, restore_data, creator_profile_data):
"""Target function for the single-worker thread."""
try:
worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread
except Exception as e:
self._log(f"❌ CRITICAL ERROR in single-worker thread: {e}")
self._log(traceback.format_exc())
finally:
self.is_running = False
def _fetch_and_queue_posts_for_pool(self, config, restore_data):
""" """
Fetches all posts from the API and submits them as tasks to a thread pool. Fetches posts from the API in batches and submits them as tasks to a thread pool.
This method runs in its own dedicated thread to avoid blocking. This method runs in its own dedicated thread to avoid blocking the UI.
It provides immediate feedback as soon as the first batch of posts is found.
""" """
try: try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS) num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set() session_processed_ids = set(restore_data.get('processed_post_ids', [])) if restore_data else set()
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_ids) processed_ids = session_processed_ids.union(profile_processed_ids)
if restore_data: if restore_data and 'all_posts_data' in restore_data:
# This logic for session restore remains as it relies on a pre-fetched list
all_posts = restore_data['all_posts_data'] all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids'])
posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids] posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids]
self.total_posts = len(all_posts) self.total_posts = len(all_posts)
self.processed_posts = len(processed_ids) self.processed_posts = len(processed_ids)
self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.") self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.")
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
if not posts_to_process:
self._log("✅ No new posts to process from restored session.")
return
for post_data in posts_to_process:
if self.cancellation_event.is_set(): break
worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
else: else:
posts_to_process = self._get_all_posts(config) # --- START: REFACTORED STREAMING LOGIC ---
self.total_posts = len(posts_to_process) post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
start_page=config.get('start_page'),
end_page=config.get('end_page'),
manga_mode=config.get('manga_mode_active', False),
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=config.get('use_cookie', False),
cookie_text=config.get('cookie_text', ''),
selected_cookie_file=config.get('selected_cookie_file'),
app_base_dir=config.get('app_base_dir'),
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
processed_post_ids=list(processed_ids)
)
self.total_posts = 0
self.processed_posts = 0 self.processed_posts = 0
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) # Process posts in batches as they are yielded by the API client
for batch in post_generator:
if self.cancellation_event.is_set():
self._log(" Post fetching cancelled.")
break
if not posts_to_process: # Filter out any posts that might have been processed since the start
self._log("✅ No new posts to process.") posts_in_batch_to_process = [p for p in batch if p.get('id') not in processed_ids]
return
for post_data in posts_to_process: if not posts_in_batch_to_process:
if self.cancellation_event.is_set(): continue
break
worker = PostProcessorWorker(post_data, config, self.progress_queue) # Update total count and immediately inform the UI
future = self.thread_pool.submit(worker.process) self.total_posts += len(posts_in_batch_to_process)
future.add_done_callback(self._handle_future_result) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
self.active_futures.append(future)
for post_data in posts_in_batch_to_process:
if self.cancellation_event.is_set(): break
worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
if self.total_posts == 0 and not self.cancellation_event.is_set():
self._log("✅ No new posts found to process.")
except Exception as e: except Exception as e:
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}") self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
@@ -165,28 +197,6 @@ class DownloadManager:
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
}) })
def _get_all_posts(self, config):
"""Helper to fetch all posts using the API client."""
all_posts = []
post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
start_page=config.get('start_page'),
end_page=config.get('end_page'),
manga_mode=config.get('manga_mode_active', False),
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=config.get('use_cookie', False),
cookie_text=config.get('cookie_text', ''),
selected_cookie_file=config.get('selected_cookie_file'),
app_base_dir=config.get('app_base_dir'),
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
processed_post_ids=config.get('processed_post_ids', [])
)
for batch in post_generator:
all_posts.extend(batch)
return all_posts
def _handle_future_result(self, future: Future): def _handle_future_result(self, future: Future):
"""Callback executed when a worker task completes.""" """Callback executed when a worker task completes."""
if self.cancellation_event.is_set(): if self.cancellation_event.is_set():
@@ -261,9 +271,15 @@ class DownloadManager:
"""Cancels the current running session.""" """Cancels the current running session."""
if not self.is_running: if not self.is_running:
return return
if self.cancellation_event.is_set():
self._log(" Cancellation already in progress.")
return
self._log("⚠️ Cancellation requested by user...") self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set() self.cancellation_event.set()
if self.thread_pool:
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.is_running = False if self.thread_pool:
self._log(" Signaling all worker threads to stop and shutting down pool...")
self.thread_pool.shutdown(wait=False)

View File

@@ -1,4 +1,5 @@
import os import os
import sys
import queue import queue
import re import re
import threading import threading
@@ -1175,11 +1176,18 @@ class PostProcessorWorker:
if FPDF: if FPDF:
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...") self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
pdf = PDF() pdf = PDF()
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# If the application is run as a bundled exe, _MEIPASS is the temp folder
base_path = sys._MEIPASS
else:
# If running as a normal .py script, use the project_root_dir
base_path = self.project_root_dir
font_path = "" font_path = ""
bold_font_path = "" bold_font_path = ""
if self.project_root_dir: if base_path:
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf') bold_font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
try: try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}") if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
@@ -1666,10 +1674,12 @@ class PostProcessorWorker:
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0: 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 path_to_check_for_emptiness = determined_post_save_path_for_history
try: try:
# Check if the path is a directory and if it's empty
if os.path.isdir(path_to_check_for_emptiness) and not os.listdir(path_to_check_for_emptiness): 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}'") self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'")
os.rmdir(path_to_check_for_emptiness) os.rmdir(path_to_check_for_emptiness)
except OSError as e_rmdir: except OSError as e_rmdir:
# Log if removal fails for any reason (e.g., permissions)
self.logger(f" ⚠️ Could not remove empty post-specific 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}")
result_tuple = (total_downloaded_this_post, total_skipped_this_post, result_tuple = (total_downloaded_this_post, total_skipped_this_post,
@@ -1678,6 +1688,15 @@ class PostProcessorWorker:
None) None)
finally: finally:
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)
except OSError as e_rmdir:
self.logger(f" ⚠️ Could not remove potentially empty subfolder '{path_to_check_for_emptiness}': {e_rmdir}")
self._emit_signal('worker_finished', result_tuple) self._emit_signal('worker_finished', result_tuple)
return result_tuple return result_tuple

View File

@@ -17,7 +17,6 @@ MAX_CHUNK_DOWNLOAD_RETRIES = 1
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk
# Flag to indicate if this module and its dependencies are available. # Flag to indicate if this module and its dependencies are available.
# This was missing and caused the ImportError.
MULTIPART_DOWNLOADER_AVAILABLE = True MULTIPART_DOWNLOADER_AVAILABLE = True
@@ -49,87 +48,97 @@ def _download_individual_chunk(
time.sleep(0.2) time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.") logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
# Prepare headers for the specific byte range of this chunk # --- START: FIX ---
chunk_headers = headers.copy() # Set this chunk's status to 'active' before starting the download.
if end_byte != -1: with progress_data['lock']:
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}" progress_data['chunks_status'][part_num]['active'] = True
# --- END: FIX ---
bytes_this_chunk = 0 try:
last_speed_calc_time = time.time() # Prepare headers for the specific byte range of this chunk
bytes_at_last_speed_calc = 0 chunk_headers = headers.copy()
if end_byte != -1:
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
# --- Retry Loop --- bytes_this_chunk = 0
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1): last_speed_calc_time = time.time()
if cancellation_event and cancellation_event.is_set(): bytes_at_last_speed_calc = 0
return bytes_this_chunk, False
try: # --- Retry Loop ---
if attempt > 0: for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...") if cancellation_event and cancellation_event.is_set():
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1))) return bytes_this_chunk, False
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = bytes_this_chunk
logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}") try:
if attempt > 0:
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...")
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = bytes_this_chunk
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk) logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}")
response.raise_for_status()
# --- Data Writing Loop --- response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
with open(temp_file_path, 'r+b') as f: response.raise_for_status()
f.seek(start_byte)
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set():
return bytes_this_chunk, False
if pause_event and pause_event.is_set():
# Handle pausing during the download stream
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.")
if data_segment: # --- Data Writing Loop ---
f.write(data_segment) with open(temp_file_path, 'r+b') as f:
bytes_this_chunk += len(data_segment) f.seek(start_byte)
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set():
return bytes_this_chunk, False
if pause_event and pause_event.is_set():
# Handle pausing during the download stream
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.")
# Update shared progress data structure if data_segment:
with progress_data['lock']: f.write(data_segment)
progress_data['total_downloaded_so_far'] += len(data_segment) bytes_this_chunk += len(data_segment)
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
# Calculate and update speed for this chunk # Update shared progress data structure
current_time = time.time() with progress_data['lock']:
time_delta = current_time - last_speed_calc_time progress_data['total_downloaded_so_far'] += len(data_segment)
if time_delta > 0.5: progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
last_speed_calc_time = current_time
bytes_at_last_speed_calc = bytes_this_chunk
# Emit progress signal to the UI via the queue # Calculate and update speed for this chunk
if emitter and (current_time - global_emit_time_ref[0] > 0.25): current_time = time.time()
global_emit_time_ref[0] = current_time time_delta = current_time - last_speed_calc_time
status_list_copy = [dict(s) for s in progress_data['chunks_status']] if time_delta > 0.5:
if isinstance(emitter, queue.Queue): bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)}) current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0
elif hasattr(emitter, 'file_progress_signal'): progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
emitter.file_progress_signal.emit(api_original_filename, status_list_copy) last_speed_calc_time = current_time
bytes_at_last_speed_calc = bytes_this_chunk
# If we reach here, the download for this chunk was successful # Emit progress signal to the UI via the queue
return bytes_this_chunk, True if emitter and (current_time - global_emit_time_ref[0] > 0.25):
global_emit_time_ref[0] = current_time
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
if isinstance(emitter, queue.Queue):
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter, 'file_progress_signal'):
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e: return bytes_this_chunk, True
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
except requests.exceptions.RequestException as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
return bytes_this_chunk, False # Break loop on non-retryable errors
except Exception as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False
return bytes_this_chunk, False except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
except requests.exceptions.RequestException as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
return bytes_this_chunk, False # Break loop on non-retryable errors
except Exception as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False
return bytes_this_chunk, False
finally:
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0.0
def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename, def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename,

View File

@@ -960,15 +960,16 @@ class EmptyPopupDialog (QDialog ):
self .parent_app .log_signal .emit (f" Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.") self .parent_app .log_signal .emit (f" Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.")
# --- START: MODIFIED LOGIC ---
# Removed the blockSignals(True/False) calls to allow the main window's UI to update correctly.
if self .parent_app .link_input : if self .parent_app .link_input :
self .parent_app .link_input .blockSignals (True )
self .parent_app .link_input .setText ( self .parent_app .link_input .setText (
self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts ) self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts )
) )
self .parent_app .link_input .blockSignals (False )
self .parent_app .link_input .setPlaceholderText ( self .parent_app .link_input .setPlaceholderText (
self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue ) self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue )
) )
# --- END: MODIFIED LOGIC ---
self.selected_creators_for_queue.clear() self.selected_creators_for_queue.clear()
@@ -989,15 +990,12 @@ class EmptyPopupDialog (QDialog ):
self .add_selected_button .setEnabled (True ) self .add_selected_button .setEnabled (True )
self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection")) self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
def _get_domain_for_service (self ,service_name ): def _get_domain_for_service (self ,service_name ):
"""Determines the base domain for a given service.""" """Determines the base domain for a given service."""
service_lower =service_name .lower () service_lower =service_name .lower ()
if service_lower in ['onlyfans','fansly']: if service_lower in ['onlyfans','fansly']:
return "coomer.su" return "coomer.st"
return "kemono.su" return "kemono.cr"
def _handle_add_selected (self ): def _handle_add_selected (self ):
"""Gathers globally selected creators and processes them.""" """Gathers globally selected creators and processes them."""

View File

@@ -15,7 +15,8 @@ from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...config.constants import ( from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY, THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY,
COOKIE_TEXT_KEY, USE_COOKIE_KEY
) )
@@ -89,7 +90,9 @@ class FutureSettingsDialog(QDialog):
# Default Path # Default Path
self.default_path_label = QLabel() self.default_path_label = QLabel()
self.save_path_button = QPushButton() self.save_path_button = QPushButton()
self.save_path_button.clicked.connect(self._save_download_path) # --- START: MODIFIED LOGIC ---
self.save_path_button.clicked.connect(self._save_cookie_and_path)
# --- END: MODIFIED LOGIC ---
download_window_layout.addWidget(self.default_path_label, 1, 0) download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1) download_window_layout.addWidget(self.save_path_button, 1, 1)
@@ -143,11 +146,13 @@ class FutureSettingsDialog(QDialog):
self.default_path_label.setText(self._tr("default_path_label", "Default Path:")) self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file")) self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
# --- START: MODIFIED LOGIC ---
# Buttons and Controls # Buttons and Controls
self._update_theme_toggle_button_text() self._update_theme_toggle_button_text()
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path")) self.save_path_button.setText(self._tr("settings_save_cookie_path_button", "Save Cookie + Download Path"))
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions.")) self.save_path_button.setToolTip(self._tr("settings_save_cookie_path_tooltip", "Save the current 'Download Location' and Cookie settings for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK")) self.ok_button.setText(self._tr("ok_button", "OK"))
# --- END: MODIFIED LOGIC ---
# Populate dropdowns # Populate dropdowns
self._populate_display_combo_boxes() self._populate_display_combo_boxes()
@@ -275,22 +280,43 @@ class FutureSettingsDialog(QDialog):
if msg_box.clickedButton() == restart_button: if msg_box.clickedButton() == restart_button:
self.parent_app._request_restart_application() self.parent_app._request_restart_application()
def _save_download_path(self): def _save_cookie_and_path(self):
"""Saves the current download path and/or cookie settings from the main window."""
path_saved = False
cookie_saved = False
# --- Save Download Path Logic ---
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input: if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip() current_path = self.parent_app.dir_input.text().strip()
if current_path and os.path.isdir(current_path): if current_path and os.path.isdir(current_path):
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path) self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
self.parent_app.settings.sync() path_saved = True
QMessageBox.information(self,
self._tr("settings_save_path_success_title", "Path Saved"), # --- Save Cookie Logic ---
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path)) if hasattr(self.parent_app, 'use_cookie_checkbox'):
elif not current_path: use_cookie = self.parent_app.use_cookie_checkbox.isChecked()
QMessageBox.warning(self, cookie_content = self.parent_app.cookie_text_input.text().strip()
self._tr("settings_save_path_empty_title", "Empty Path"),
self._tr("settings_save_path_empty_message", "Download location cannot be empty.")) if use_cookie and cookie_content:
else: self.parent_app.settings.setValue(USE_COOKIE_KEY, True)
QMessageBox.warning(self, self.parent_app.settings.setValue(COOKIE_TEXT_KEY, cookie_content)
self._tr("settings_save_path_invalid_title", "Invalid Path"), cookie_saved = True
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path)) else: # Also save the 'off' state
self.parent_app.settings.setValue(USE_COOKIE_KEY, False)
self.parent_app.settings.setValue(COOKIE_TEXT_KEY, "")
self.parent_app.settings.sync()
# --- User Feedback ---
if path_saved and cookie_saved:
message = self._tr("settings_save_both_success", "Download location and cookie settings saved.")
elif path_saved:
message = self._tr("settings_save_path_only_success", "Download location saved. No cookie settings were active to save.")
elif cookie_saved:
message = self._tr("settings_save_cookie_only_success", "Cookie settings saved. Download location was not set.")
else: else:
QMessageBox.critical(self, "Error", "Could not access download path input from main application.") QMessageBox.warning(self, self._tr("settings_save_nothing_title", "Nothing to Save"),
self._tr("settings_save_nothing_message", "The download location is not a valid directory and no cookie was active."))
return
QMessageBox.information(self, self._tr("settings_save_success_title", "Settings Saved"), message)

View File

@@ -24,7 +24,7 @@ class MoreOptionsDialog(QDialog):
layout.addWidget(self.description_label) layout.addWidget(self.description_label)
self.radio_button_group = QButtonGroup(self) self.radio_button_group = QButtonGroup(self)
self.radio_content = QRadioButton("Description/Content") self.radio_content = QRadioButton("Description/Content")
self.radio_comments = QRadioButton("Comments (Not Working)") self.radio_comments = QRadioButton("Comments")
self.radio_button_group.addButton(self.radio_content) self.radio_button_group.addButton(self.radio_content)
self.radio_button_group.addButton(self.radio_comments) self.radio_button_group.addButton(self.radio_comments)
layout.addWidget(self.radio_content) layout.addWidget(self.radio_content)

View File

@@ -105,6 +105,7 @@ class DownloaderApp (QWidget ):
self.active_update_profile = None self.active_update_profile = None
self.new_posts_for_update = [] self.new_posts_for_update = []
self.is_finishing = False self.is_finishing = False
self.finish_lock = threading.Lock()
saved_res = self.settings.value(RESOLUTION_KEY, "Auto") saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
if saved_res != "Auto": if saved_res != "Auto":
@@ -266,7 +267,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None self.download_location_label_widget = None
self.remove_from_filename_label_widget = None self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v6.2.0") self.setWindowTitle("Kemono Downloader v6.2.1")
setup_ui(self) setup_ui(self)
self._connect_signals() self._connect_signals()
self.log_signal.emit(" Local API server functionality has been removed.") self.log_signal.emit(" Local API server functionality has been removed.")
@@ -284,6 +285,7 @@ class DownloaderApp (QWidget ):
self._retranslate_main_ui() self._retranslate_main_ui()
self._load_persistent_history() self._load_persistent_history()
self._load_saved_download_location() self._load_saved_download_location()
self._load_saved_cookie_settings()
self._update_button_states_and_connections() self._update_button_states_and_connections()
self._check_for_interrupted_session() self._check_for_interrupted_session()
@@ -1570,6 +1572,31 @@ class DownloaderApp (QWidget ):
QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }") QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }")
def handle_main_log(self, message): def handle_main_log(self, message):
if isinstance(message, str) and message.startswith("MANGA_FETCH_PROGRESS:"):
try:
parts = message.split(":")
fetched_count = int(parts[1])
page_num = int(parts[2])
self.progress_label.setText(self._tr("progress_fetching_manga_pages", "Progress: Fetching Page {page} ({count} posts found)...").format(page=page_num, count=fetched_count))
QCoreApplication.processEvents()
except (ValueError, IndexError):
try:
fetched_count = int(message.split(":")[1])
self.progress_label.setText(self._tr("progress_fetching_manga_posts", "Progress: Fetching Manga Posts ({count})...").format(count=fetched_count))
QCoreApplication.processEvents()
except (ValueError, IndexError):
pass
return
elif isinstance(message, str) and message.startswith("MANGA_FETCH_COMPLETE:"):
try:
total_posts = int(message.split(":")[1])
self.total_posts_to_process = total_posts
self.processed_posts_count = 0
self.update_progress_display(self.total_posts_to_process, self.processed_posts_count)
except (ValueError, IndexError):
pass
return
if message.startswith("TEMP_FILE_PATH:"): if message.startswith("TEMP_FILE_PATH:"):
filepath = message.split(":", 1)[1] filepath = message.split(":", 1)[1]
if self.single_pdf_setting: if self.single_pdf_setting:
@@ -2561,23 +2588,42 @@ class DownloaderApp (QWidget ):
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).") self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).")
def _toggle_manga_filename_style (self ): def _toggle_manga_filename_style (self ):
current_style =self .manga_filename_style url_text = self.link_input.text().strip() if self.link_input else ""
new_style ="" _, _, post_id = extract_post_info(url_text)
if current_style ==STYLE_POST_TITLE : is_single_post = bool(post_id)
new_style =STYLE_ORIGINAL_NAME
elif current_style ==STYLE_ORIGINAL_NAME : current_style = self.manga_filename_style
new_style =STYLE_DATE_POST_TITLE new_style = ""
elif current_style ==STYLE_DATE_POST_TITLE :
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING if is_single_post:
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : # Cycle through a limited set of styles suitable for single posts
new_style =STYLE_DATE_BASED if current_style == STYLE_POST_TITLE:
elif current_style ==STYLE_DATE_BASED : new_style = STYLE_DATE_POST_TITLE
new_style =STYLE_POST_ID # Change this line elif current_style == STYLE_DATE_POST_TITLE:
elif current_style ==STYLE_POST_ID: # Add this block new_style = STYLE_ORIGINAL_NAME
new_style =STYLE_POST_TITLE elif current_style == STYLE_ORIGINAL_NAME:
else : new_style = STYLE_POST_ID
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').") elif current_style == STYLE_POST_ID:
new_style =STYLE_POST_TITLE new_style = STYLE_POST_TITLE
else: # Fallback for any other style
new_style = STYLE_POST_TITLE
else:
# Original cycling logic for creator feeds
if current_style ==STYLE_POST_TITLE :
new_style =STYLE_ORIGINAL_NAME
elif current_style ==STYLE_ORIGINAL_NAME :
new_style =STYLE_DATE_POST_TITLE
elif current_style ==STYLE_DATE_POST_TITLE :
new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
new_style =STYLE_DATE_BASED
elif current_style ==STYLE_DATE_BASED :
new_style =STYLE_POST_ID
elif current_style ==STYLE_POST_ID:
new_style =STYLE_POST_TITLE
else :
self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
new_style =STYLE_POST_TITLE
self .manga_filename_style =new_style self .manga_filename_style =new_style
self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style ) self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
@@ -2643,16 +2689,32 @@ class DownloaderApp (QWidget ):
url_text =self .link_input .text ().strip ()if self .link_input else "" url_text =self .link_input .text ().strip ()if self .link_input else ""
_ ,_ ,post_id =extract_post_info (url_text ) _ ,_ ,post_id =extract_post_info (url_text )
# --- START: MODIFIED LOGIC ---
is_creator_feed =not post_id if url_text else False is_creator_feed =not post_id if url_text else False
is_single_post = bool(post_id)
is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
# If the download queue contains items selected from the popup, treat it as a single-post context for UI purposes.
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
# Allow Manga Mode checkbox for any valid URL (creator or single post) or if single posts are queued.
can_enable_manga_checkbox = (is_creator_feed or is_single_post) and not is_favorite_mode_on
if self .manga_mode_checkbox : if self .manga_mode_checkbox :
self .manga_mode_checkbox .setEnabled (is_creator_feed and not is_favorite_mode_on ) self .manga_mode_checkbox .setEnabled (can_enable_manga_checkbox)
if not is_creator_feed and self .manga_mode_checkbox .isChecked (): if not can_enable_manga_checkbox and self .manga_mode_checkbox .isChecked ():
self .manga_mode_checkbox .setChecked (False ) self .manga_mode_checkbox .setChecked (False )
checked =self .manga_mode_checkbox .isChecked () checked =self .manga_mode_checkbox .isChecked ()
manga_mode_effectively_on =is_creator_feed and checked manga_mode_effectively_on = can_enable_manga_checkbox and checked
# If it's a single post context, prevent sequential styles from being selected as they don't apply.
sequential_styles = [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
if is_single_post and self.manga_filename_style in sequential_styles:
self.manga_filename_style = STYLE_POST_TITLE # Default to a safe, non-sequential style
self._update_manga_filename_style_button_text()
# --- END: MODIFIED LOGIC ---
if self .manga_rename_toggle_button : if self .manga_rename_toggle_button :
self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )) self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
@@ -2762,7 +2824,9 @@ class DownloaderApp (QWidget ):
if total_posts >0 or processed_posts >0 : if total_posts >0 or processed_posts >0 :
self .file_progress_label .setText ("") self .file_progress_label .setText ("")
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False): def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False, item_type_from_queue=None):
self.finish_lock = threading.Lock()
self.is_finishing = False
if self.active_update_profile: if self.active_update_profile:
if not self.new_posts_for_update: if not self.new_posts_for_update:
return self._check_for_updates() return self._check_for_updates()
@@ -2888,17 +2952,30 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
service, user_id, post_id_from_url = extract_post_info(api_url) service, user_id, post_id_from_url = extract_post_info(api_url)
# --- START: MODIFIED SECTION ---
# This check is now smarter. It only triggers the error if the item from the queue
# was supposed to be a post ('single_post_from_popup', etc.) but couldn't be parsed.
if direct_api_url and not post_id_from_url and item_type_from_queue and 'post' in item_type_from_queue:
self.log_signal.emit(f"❌ CRITICAL ERROR: Could not parse post ID from the queued POST URL: {api_url}")
self.log_signal.emit(" Skipping this item. This might be due to an unsupported URL format or a temporary issue.")
self.download_finished(
total_downloaded=0,
total_skipped=1,
cancelled_by_user=False,
kept_original_names_list=[]
)
return False
# --- END: MODIFIED SECTION ---
if not service or not user_id: if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.") QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False return False
# Read the setting at the start of the download
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool) self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
profile_processed_ids = set() # Default to an empty set creator_profile_data = {}
if self.save_creator_json_enabled_this_session: if self.save_creator_json_enabled_this_session:
# --- CREATOR PROFILE LOGIC ---
creator_name_for_profile = None creator_name_for_profile = None
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info: if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder') creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
@@ -2912,7 +2989,6 @@ class DownloaderApp (QWidget ):
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path) creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
# Get all current UI settings and add them to the profile
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run) current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
creator_profile_data['settings'] = current_settings creator_profile_data['settings'] = current_settings
@@ -2924,10 +3000,17 @@ class DownloaderApp (QWidget ):
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path) self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.") self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', [])) profile_processed_ids = set()
# --- END OF PROFILE LOGIC ---
if self.active_update_profile:
self.log_signal.emit(" Update session active: Loading existing processed post IDs to find new content.")
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
elif not is_restore:
self.log_signal.emit(" Fresh download session: Clearing previous post history for this creator to re-download all.")
if 'processed_post_ids' in creator_profile_data:
creator_profile_data['processed_post_ids'] = []
# The rest of this logic runs regardless, but uses the profile data if it was loaded
session_processed_ids = set(processed_post_ids_for_restore) session_processed_ids = set(processed_post_ids_for_restore)
combined_processed_ids = session_processed_ids.union(profile_processed_ids) combined_processed_ids = session_processed_ids.union(profile_processed_ids)
processed_post_ids_for_this_run = list(combined_processed_ids) processed_post_ids_for_this_run = list(combined_processed_ids)
@@ -3055,7 +3138,7 @@ class DownloaderApp (QWidget ):
elif backend_filter_mode == 'audio': current_mode_log_text = "Audio Download" elif backend_filter_mode == 'audio': current_mode_log_text = "Audio Download"
current_char_filter_scope = self.get_char_filter_scope() current_char_filter_scope = self.get_char_filter_scope()
manga_mode = manga_mode_is_checked and not post_id_from_url manga_mode = manga_mode_is_checked
manga_date_prefix_text = "" manga_date_prefix_text = ""
if manga_mode and (self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_ORIGINAL_NAME) and hasattr(self, 'manga_date_prefix_input'): if manga_mode and (self.manga_filename_style == STYLE_DATE_BASED or self.manga_filename_style == STYLE_ORIGINAL_NAME) and hasattr(self, 'manga_date_prefix_input'):
@@ -3478,6 +3561,7 @@ class DownloaderApp (QWidget ):
if hasattr (self .download_thread ,'file_progress_signal'):self .download_thread .file_progress_signal .connect (self .update_file_progress_display ) if hasattr (self .download_thread ,'file_progress_signal'):self .download_thread .file_progress_signal .connect (self .update_file_progress_display )
if hasattr (self .download_thread ,'missed_character_post_signal'): if hasattr (self .download_thread ,'missed_character_post_signal'):
self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post ) self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post )
if hasattr(self.download_thread, 'overall_progress_signal'): self.download_thread.overall_progress_signal.connect(self.update_progress_display)
if hasattr (self .download_thread ,'retryable_file_failed_signal'): if hasattr (self .download_thread ,'retryable_file_failed_signal'):
if hasattr (self .download_thread ,'file_successfully_downloaded_signal'): if hasattr (self .download_thread ,'file_successfully_downloaded_signal'):
@@ -3862,7 +3946,12 @@ class DownloaderApp (QWidget ):
if not filepath.lower().endswith('.pdf'): if not filepath.lower().endswith('.pdf'):
filepath += '.pdf' filepath += '.pdf'
font_path = os.path.join(self.app_base_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf') if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
base_path = sys._MEIPASS
else:
base_path = self.app_base_dir
font_path = os.path.join(base_path, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
self.log_signal.emit(" Sorting collected posts by date (oldest first)...") self.log_signal.emit(" Sorting collected posts by date (oldest first)...")
sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z')) sorted_content = sorted(posts_content_data, key=lambda x: x.get('published', 'Z'))
@@ -4182,9 +4271,12 @@ class DownloaderApp (QWidget ):
# Update UI to "Cancelling" state # Update UI to "Cancelling" state
self.pause_btn.setEnabled(False) self.pause_btn.setEnabled(False)
self.cancel_btn.setEnabled(False) self.cancel_btn.setEnabled(False)
if hasattr(self, 'reset_button'):
self.reset_button.setEnabled(False)
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait.")) self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
# Signal all active components to stop
if self.download_thread and self.download_thread.isRunning(): if self.download_thread and self.download_thread.isRunning():
self.download_thread.requestInterruption() self.download_thread.requestInterruption()
self.log_signal.emit(" Signaled single download thread to interrupt.") self.log_signal.emit(" Signaled single download thread to interrupt.")
@@ -4199,22 +4291,27 @@ class DownloaderApp (QWidget ):
def _get_domain_for_service (self ,service_name :str )->str : def _get_domain_for_service (self ,service_name :str )->str :
"""Determines the base domain for a given service.""" """Determines the base domain for a given service."""
if not isinstance (service_name ,str ): if not isinstance (service_name ,str ):
return "kemono.su" return "kemono.cr"
service_lower =service_name .lower () service_lower =service_name .lower ()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'} coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'}
if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']: if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']:
return "coomer.su" return "coomer.st"
return "kemono.su" return "kemono.cr"
def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None): def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None):
if self.is_finishing: if not self.finish_lock.acquire(blocking=False):
return return
self.is_finishing = True
try: try:
if self.is_finishing:
return
self.is_finishing = True
if cancelled_by_user: if cancelled_by_user:
self.log_signal.emit("✅ Cancellation complete. Resetting UI.") self.log_signal.emit("✅ Cancellation complete. Resetting UI.")
self._clear_session_file()
self.interrupted_session_data = None
self.is_restore_pending = False
current_url = self.link_input.text() current_url = self.link_input.text()
current_dir = self.dir_input.text() current_dir = self.dir_input.text()
self._perform_soft_ui_reset(preserve_url=current_url, preserve_dir=current_dir) self._perform_soft_ui_reset(preserve_url=current_url, preserve_dir=current_dir)
@@ -4222,13 +4319,14 @@ class DownloaderApp (QWidget ):
self.file_progress_label.setText("") self.file_progress_label.setText("")
if self.pause_event: self.pause_event.clear() if self.pause_event: self.pause_event.clear()
self.is_paused = False self.is_paused = False
return # Exit after handling cancellation return
self.log_signal.emit("🏁 Download of current item complete.") self.log_signal.emit("🏁 Download of current item complete.")
if self.is_processing_favorites_queue and self.favorite_download_queue: if self.is_processing_favorites_queue and self.favorite_download_queue:
self.log_signal.emit("✅ Item finished. Processing next in queue...") self.log_signal.emit("✅ Item finished. Processing next in queue...")
self.is_finishing = False # Allow the next item in queue to start self.is_finishing = False
self.finish_lock.release()
self._process_next_favorite_download() self._process_next_favorite_download()
return return
@@ -4317,6 +4415,7 @@ class DownloaderApp (QWidget ):
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
self.is_finishing = False # Allow retry session to start self.is_finishing = False # Allow retry session to start
self.finish_lock.release() # Release lock for the retry session
self._start_failed_files_retry_session() self._start_failed_files_retry_session()
return # Exit to allow retry session to run return # Exit to allow retry session to run
else: else:
@@ -4334,7 +4433,7 @@ class DownloaderApp (QWidget ):
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
self.active_update_profile = None self.active_update_profile = None
finally: finally:
self.is_finishing = False pass
def _handle_keep_duplicates_toggled(self, checked): def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is checked.""" """Shows the duplicate handling dialog when the checkbox is checked."""
@@ -5164,6 +5263,31 @@ class DownloaderApp (QWidget ):
if hasattr(self, 'link_input'): if hasattr(self, 'link_input'):
self.last_link_input_text_for_queue_sync = self.link_input.text() 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:
use_cookie_saved = self.settings.value(USE_COOKIE_KEY, False, type=bool)
cookie_content_saved = self.settings.value(COOKIE_TEXT_KEY, "", type=str)
if use_cookie_saved and cookie_content_saved:
self.use_cookie_checkbox.setChecked(True)
self.cookie_text_input.setText(cookie_content_saved)
# Check if the saved content is a file path and update UI accordingly
if os.path.exists(cookie_content_saved):
self.selected_cookie_filepath = cookie_content_saved
self.cookie_text_input.setReadOnly(True)
self._update_cookie_input_placeholders_and_tooltips()
self.log_signal.emit(f" Loaded saved cookie settings.")
except Exception as e:
self.log_signal.emit(f"⚠️ Could not load saved cookie settings: {e}")
def _show_favorite_artists_dialog (self ): def _show_favorite_artists_dialog (self ):
if self ._is_download_active ()or self .is_processing_favorites_queue : if self ._is_download_active ()or self .is_processing_favorites_queue :
QMessageBox .warning (self ,"Busy","Another download operation is already in progress.") QMessageBox .warning (self ,"Busy","Another download operation is already in progress.")
@@ -5285,7 +5409,7 @@ class DownloaderApp (QWidget ):
else : else :
self .log_signal .emit (" Favorite posts selection cancelled.") self .log_signal .emit (" Favorite posts selection cancelled.")
def _process_next_favorite_download (self ): def _process_next_favorite_download(self):
if self.favorite_download_queue and not self.is_processing_favorites_queue: if self.favorite_download_queue and not self.is_processing_favorites_queue:
manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
@@ -5330,33 +5454,43 @@ class DownloaderApp (QWidget ):
next_url =self .current_processing_favorite_item_info ['url'] next_url =self .current_processing_favorite_item_info ['url']
item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item') item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item')
item_type =self .current_processing_favorite_item_info .get ('type','artist') # --- START: MODIFIED SECTION ---
self .log_signal .emit (f"▶️ Processing next favorite from queue: '{item_display_name }' ({next_url })") # Get the type of item from the queue to help start_download make smarter decisions.
item_type = self.current_processing_favorite_item_info.get('type', 'artist')
self.log_signal.emit(f"▶️ Processing next favorite from queue ({item_type}): '{item_display_name}' ({next_url})")
override_dir =None override_dir = None
item_scope =self .current_processing_favorite_item_info .get ('scope_from_popup') item_scope = self.current_processing_favorite_item_info.get('scope_from_popup')
if item_scope is None : if item_scope is None:
item_scope =self .favorite_download_scope item_scope = self.favorite_download_scope
main_download_dir =self .dir_input .text ().strip () main_download_dir = self.dir_input.text().strip()
should_create_artist_folder =False should_create_artist_folder = False
if item_type =='creator_popup_selection'and item_scope ==EmptyPopupDialog .SCOPE_CREATORS : if item_type == 'creator_popup_selection' and item_scope == EmptyPopupDialog.SCOPE_CREATORS:
should_create_artist_folder =True should_create_artist_folder = True
elif item_type !='creator_popup_selection'and self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS : elif item_type != 'creator_popup_selection' and self.favorite_download_scope == FAVORITE_SCOPE_ARTIST_FOLDERS:
should_create_artist_folder =True should_create_artist_folder = True
if should_create_artist_folder and main_download_dir : if should_create_artist_folder and main_download_dir:
folder_name_key =self .current_processing_favorite_item_info .get ('name_for_folder','Unknown_Folder') folder_name_key = self.current_processing_favorite_item_info.get('name_for_folder', 'Unknown_Folder')
item_specific_folder_name =clean_folder_name (folder_name_key ) item_specific_folder_name = clean_folder_name(folder_name_key)
override_dir =os .path .normpath (os .path .join (main_download_dir ,item_specific_folder_name )) override_dir = os.path.normpath(os.path.join(main_download_dir, item_specific_folder_name))
self .log_signal .emit (f" Scope requires artist folder. Target directory: '{override_dir }'") self.log_signal.emit(f" Scope requires artist folder. Target directory: '{override_dir}'")
success_starting_download =self .start_download (direct_api_url =next_url ,override_output_dir =override_dir, is_continuation=True ) # Pass the item_type to the start_download function
success_starting_download = self.start_download(
direct_api_url=next_url,
override_output_dir=override_dir,
is_continuation=True,
item_type_from_queue=item_type
)
# --- END: MODIFIED SECTION ---
if not success_starting_download : if not success_starting_download:
self .log_signal .emit (f"⚠️ Failed to initiate download for '{item_display_name }'. Skipping this item in queue.") self.log_signal.emit(f"⚠️ Failed to initiate download for '{item_display_name}'. Skipping and moving to the next item in queue.")
self .download_finished (total_downloaded =0 ,total_skipped =1 ,cancelled_by_user =True ,kept_original_names_list =[]) # Use a QTimer to avoid deep recursion and correctly move to the next item.
QTimer.singleShot(100, self._process_next_favorite_download)
class ExternalLinkDownloadThread (QThread ): class ExternalLinkDownloadThread (QThread ):
"""A QThread to handle downloading multiple external links sequentially.""" """A QThread to handle downloading multiple external links sequentially."""

View File

@@ -196,10 +196,9 @@ def get_link_platform(url):
if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x' if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x'
if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite' if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite'
if 'pixiv.net' in domain: return 'pixiv' if 'pixiv.net' in domain: return 'pixiv'
if 'kemono.su' in domain or 'kemono.party' in domain: return 'kemono' if 'kemono.su' in domain or 'kemono.party' in domain or 'kemono.cr' in domain: return 'kemono'
if 'coomer.su' in domain or 'coomer.party' in domain: return 'coomer' if 'coomer.su' in domain or 'coomer.party' in domain or 'coomer.st' in domain: return 'coomer'
# Fallback to a generic name for other domains
parts = domain.split('.') parts = domain.split('.')
if len(parts) >= 2: if len(parts) >= 2:
return parts[-2] return parts[-2]