This commit is contained in:
Yuvi9587
2025-07-19 03:28:32 -07:00
parent 33133eb275
commit fbdae61b80
15 changed files with 194 additions and 376 deletions

View File

@@ -3,8 +3,6 @@ import traceback
from urllib.parse import urlparse
import json # Ensure json is imported
import requests
# (Keep the rest of your imports)
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import (
STYLE_DATE_POST_TITLE
@@ -25,9 +23,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
raise RuntimeError("Fetch operation cancelled by user while paused.")
time.sleep(0.5)
logger(" Post fetching resumed.")
# --- MODIFICATION: Added `fields` to the URL to request only metadata ---
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
@@ -44,7 +39,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
logger(log_message)
try:
# We can now remove the streaming logic as the response will be small and fast.
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status()
return response.json()
@@ -80,7 +74,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f" Fetching full content for post ID {post_id}...")
try:
# Use streaming here as a precaution for single posts that are still very large.
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
response.raise_for_status()
response_body = b""
@@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
response_body += chunk
full_post_data = json.loads(response_body)
# The API sometimes wraps the post in a list, handle that.
if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0]
return full_post_data
@@ -134,14 +126,10 @@ def download_from_api(
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json'
}
# --- ADD THIS BLOCK ---
# Ensure processed_post_ids is a set for fast lookups
if processed_post_ids is None:
processed_post_ids = set()
else:
processed_post_ids = set(processed_post_ids)
# --- END OF ADDITION ---
service, user_id, target_post_id = extract_post_info(api_url_input)
@@ -158,11 +146,9 @@ def download_from_api(
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)
if target_post_id:
# --- ADD THIS CHECK FOR RESTORE ---
if target_post_id in processed_post_ids:
logger(f" Skipping already processed target post ID: {target_post_id}")
return
# --- END OF ADDITION ---
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try:
@@ -248,14 +234,12 @@ def download_from_api(
break
if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode:
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
if processed_post_ids:
original_count = len(all_posts_for_manga_mode)
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(all_posts_for_manga_mode)
if skipped_count > 0:
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
# --- END OF ADDITION ---
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple(post):
@@ -326,15 +310,12 @@ def download_from_api(
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback.print_exc()
break
# --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
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}.")
# --- END OF ADDITION ---
if not posts_batch:
if target_post_id and not processed_target_post_flag:

View File

@@ -1,13 +1,9 @@
# --- Standard Library Imports ---
import threading
import time
import os
import json
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
# --- Local Application Imports ---
# These imports reflect the new, organized project structure.
from .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread
from ..config.constants import (
@@ -36,8 +32,6 @@ class DownloadManager:
self.progress_queue = progress_queue
self.thread_pool = None
self.active_futures = []
# --- Session State ---
self.cancellation_event = threading.Event()
self.pause_event = threading.Event()
self.is_running = False
@@ -64,8 +58,6 @@ class DownloadManager:
if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.")
return
# --- Reset state for the new session ---
self.is_running = True
self.cancellation_event.clear()
self.pause_event.clear()
@@ -75,8 +67,6 @@ class DownloadManager:
self.total_downloads = 0
self.total_skips = 0
self.all_kept_original_filenames = []
# --- Decide execution strategy (multi-threaded vs. single-threaded) ---
is_single_post = bool(config.get('target_post_id_from_initial_url'))
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]
@@ -84,7 +74,6 @@ class DownloadManager:
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts:
# Start a separate thread to manage fetching and queuing to the thread pool
fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data),
@@ -92,16 +81,11 @@ class DownloadManager:
)
fetcher_thread.start()
else:
# For single posts or sequential manga mode, use a single worker thread
# which is simpler and ensures order.
self._start_single_threaded_session(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...")
# The original DownloadThread is now a pure Python thread, not a QThread.
# We run its `run` method in a standard Python thread.
self.worker_thread = threading.Thread(
target=self._run_single_worker,
args=(config,),
@@ -112,7 +96,6 @@ class DownloadManager:
def _run_single_worker(self, config):
"""Target function for the single-worker thread."""
try:
# Pass the queue directly to the worker for it to send updates
worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread
except Exception as e:
@@ -129,9 +112,6 @@ class DownloadManager:
try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
# Fetch posts
# In a real implementation, this would call `api_client.download_from_api`
if restore_data:
all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids'])
@@ -149,12 +129,9 @@ class DownloadManager:
if not posts_to_process:
self._log("✅ No new posts to process.")
return
# Submit tasks to the pool
for post_data in posts_to_process:
if self.cancellation_event.is_set():
break
# Each PostProcessorWorker gets the queue to send its own updates
worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result)
@@ -164,27 +141,32 @@ class DownloadManager:
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
self._log(traceback.format_exc())
finally:
# Wait for all submitted tasks to complete before shutting down
if self.thread_pool:
self.thread_pool.shutdown(wait=True)
self.is_running = False
self._log("🏁 All processing tasks have completed.")
# Emit final signal
self.progress_queue.put({
'type': 'finished',
'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 = []
# This generator yields batches of posts
post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
# ... pass other relevant config keys ...
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
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)
@@ -203,14 +185,11 @@ class DownloadManager:
self.total_skips += 1
else:
result = future.result()
# Unpack result tuple from the worker
(dl_count, skip_count, kept_originals,
retryable, permanent, history) = result
self.total_downloads += dl_count
self.total_skips += skip_count
self.all_kept_original_filenames.extend(kept_originals)
# Queue up results for UI to handle
if retryable:
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
if permanent:
@@ -221,8 +200,6 @@ class DownloadManager:
except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped
# Update overall progress
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def cancel_session(self):
@@ -231,11 +208,7 @@ class DownloadManager:
return
self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set()
# For single thread mode, the worker checks the event
# For multi-thread mode, shut down the pool
if self.thread_pool:
# Don't wait, just cancel pending futures and let the fetcher thread exit
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.is_running = False

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import os
import queue
import re
@@ -15,15 +14,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError,
from io import BytesIO
from urllib .parse import urlparse
import requests
# --- Third-Party Library Imports ---
try:
from PIL import Image
except ImportError:
Image = None
#
try:
from fpdf import FPDF
# Add a simple class to handle the header/footer for stories
class PDF(FPDF):
def header(self):
pass # No header
@@ -39,16 +35,12 @@ try:
from docx import Document
except ImportError:
Document = None
# --- PyQt5 Imports ---
from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess
# --- Local Application Imports ---
from .api_client import download_from_api, fetch_post_comments
from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE
from ..services.drive_downloader import (
download_mega_file, download_gdrive_file, download_dropbox_file
)
# Corrected Imports:
from ..utils.file_utils import (
is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES,
clean_filename, clean_folder_name
@@ -567,10 +559,8 @@ class PostProcessorWorker:
with self.downloaded_hash_counts_lock:
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
# Default to not skipping
decision_to_skip = False
# Apply logic based on mode
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
if current_count >= 1:
decision_to_skip = True
@@ -581,12 +571,10 @@ class PostProcessorWorker:
decision_to_skip = True
self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.")
# If we are NOT skipping this file, we MUST increment the count.
if not decision_to_skip:
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
should_skip = decision_to_skip
# --- End of Final Corrected Logic ---
if should_skip:
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
@@ -678,9 +666,14 @@ class PostProcessorWorker:
else:
self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.")
details_for_failure = {
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post
'file_info': file_info,
'target_folder_path': target_folder_path,
'headers': headers,
'original_post_id_for_log': original_post_id_for_log,
'post_title': post_title,
'file_index_in_post': file_index_in_post,
'num_files_in_this_post': num_files_in_this_post,
'forced_filename_override': filename_to_save_in_main_path
}
if is_permanent_error:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, details_for_failure
@@ -1040,7 +1033,9 @@ class PostProcessorWorker:
return result_tuple
raw_text_content = ""
comments_data = []
final_post_data = post_data
if self.text_only_scope == 'content' and 'content' not in final_post_data:
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
parsed_url = urlparse(self.api_url_input)
@@ -1050,6 +1045,7 @@ class PostProcessorWorker:
full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
if full_data:
final_post_data = full_data
if self.text_only_scope == 'content':
raw_text_content = final_post_data.get('content', '')
elif self.text_only_scope == 'comments':
@@ -1060,46 +1056,46 @@ class PostProcessorWorker:
if comments_data:
comment_texts = []
for comment in comments_data:
user = comment.get('user', {}).get('name', 'Unknown User')
timestamp = comment.get('updated', 'No Date')
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n")
raw_text_content = "\n".join(comment_texts)
else:
raw_text_content = ""
except Exception as e:
self.logger(f" ❌ Error fetching comments for text-only mode: {e}")
if not raw_text_content or not raw_text_content.strip():
cleaned_text = ""
if self.text_only_scope == 'content':
if not raw_text_content:
cleaned_text = ""
else:
text_with_newlines = re.sub(r'(?i)</p>|<br\s*/?>', '\n', raw_text_content)
just_text = re.sub(r'<.*?>', '', text_with_newlines)
cleaned_text = html.unescape(just_text).strip()
else:
cleaned_text = raw_text_content
cleaned_text = cleaned_text.replace('', '...')
if not cleaned_text.strip():
self.logger(" -> Skip Saving Text: No content/comments found or fetched.")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple
paragraph_pattern = re.compile(r'<p.*?>(.*?)</p>', re.IGNORECASE | re.DOTALL)
html_paragraphs = paragraph_pattern.findall(raw_text_content)
cleaned_text = ""
if not html_paragraphs:
self.logger(" ⚠️ No <p> tags found. Falling back to basic HTML cleaning for the whole block.")
text_with_br = re.sub(r'<br\s*/?>', '\n', raw_text_content, flags=re.IGNORECASE)
cleaned_text = re.sub(r'<.*?>', '', text_with_br)
else:
cleaned_paragraphs_list = []
for p_content in html_paragraphs:
p_with_br = re.sub(r'<br\s*/?>', '\n', p_content, flags=re.IGNORECASE)
p_cleaned = re.sub(r'<.*?>', '', p_with_br)
p_final = html.unescape(p_cleaned).strip()
if p_final:
cleaned_paragraphs_list.append(p_final)
cleaned_text = '\n\n'.join(cleaned_paragraphs_list)
cleaned_text = cleaned_text.replace('', '...')
if self.single_pdf_mode:
if not cleaned_text:
result_tuple = (0, 0, [], [], [], None, None)
return result_tuple
content_data = {
'title': post_title,
'content': cleaned_text,
'published': self.post.get('published') or self.post.get('added')
}
if self.text_only_scope == 'comments':
if not comments_data: return (0, 0, [], [], [], None, None)
content_data['comments'] = comments_data
else:
if not cleaned_text.strip(): return (0, 0, [], [], [], None, None)
content_data['content'] = cleaned_text
temp_dir = os.path.join(self.app_base_dir, "appdata")
os.makedirs(temp_dir, exist_ok=True)
temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json"
@@ -1107,13 +1103,11 @@ class PostProcessorWorker:
try:
with open(temp_filepath, 'w', encoding='utf-8') as f:
json.dump(content_data, f, indent=2)
self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.")
result_tuple = (0, 0, [], [], [], None, temp_filepath)
return result_tuple
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
return (0, 0, [], [], [], None, temp_filepath)
except Exception as e:
self.logger(f" ❌ Failed to write temporary file for single PDF: {e}")
result_tuple = (0, 0, [], [], [], None, None)
return result_tuple
return (0, 0, [], [], [], None, None)
else:
file_extension = self.text_export_format
txt_filename = clean_filename(post_title) + f".{file_extension}"
@@ -1125,27 +1119,63 @@ class PostProcessorWorker:
while os.path.exists(final_save_path):
final_save_path = f"{base}_{counter}{ext}"
counter += 1
if file_extension == 'pdf':
if FPDF:
self.logger(f" Converting to PDF...")
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
pdf = PDF()
font_path = ""
bold_font_path = ""
if self.project_root_dir:
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.set_font('DejaVu', '', 12)
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
default_font_family = 'DejaVu'
except Exception as font_error:
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
pdf.set_font('Arial', '', 12)
default_font_family = 'Arial'
pdf.add_page()
pdf.multi_cell(0, 5, cleaned_text)
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(0, 10, post_title)
pdf.ln(10)
if self.text_only_scope == 'comments':
if not comments_data:
self.logger(" -> Skip PDF Creation: No comments to process.")
return (0, num_potential_files_in_post, [], [], [], None, None)
for i, comment in enumerate(comments_data):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, user)
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if i < len(comments_data) - 1:
pdf.ln(5)
pdf.cell(0, 0, '', border='T')
pdf.ln(5)
else:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(0, 7, cleaned_text)
pdf.output(final_save_path)
else:
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
elif file_extension == 'docx':
if Document:
self.logger(f" Converting to DOCX...")
@@ -1156,12 +1186,15 @@ class PostProcessorWorker:
self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.")
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
else:
else: # TXT file
with open(final_save_path, 'w', encoding='utf-8') as f:
f.write(cleaned_text)
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
return result_tuple
except Exception as e:
self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
@@ -1263,7 +1296,6 @@ class PostProcessorWorker:
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
unique_files_by_url = {}
for file_info in all_files_from_post_api:
# Use the file URL as a unique key to avoid processing the same file multiple times
file_url = file_info.get('url')
if file_url and file_url not in unique_files_by_url:
unique_files_by_url[file_url] = file_info
@@ -1734,7 +1766,6 @@ class DownloadThread(QThread):
worker_signals_obj = PostProcessorSignals()
try:
# Connect signals
worker_signals_obj.progress_signal.connect(self.progress_signal)
worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal)
worker_signals_obj.file_progress_signal.connect(self.file_progress_signal)
@@ -1771,8 +1802,6 @@ class DownloadThread(QThread):
was_process_cancelled = True
break
# --- START OF FIX: Explicitly build the arguments dictionary ---
# This robustly maps all thread attributes to the correct worker parameters.
worker_args = {
'post_data': individual_post_data,
'emitter': worker_signals_obj,
@@ -1833,7 +1862,6 @@ class DownloadThread(QThread):
'single_pdf_mode': self.single_pdf_mode,
'project_root_dir': self.project_root_dir,
}
# --- END OF FIX ---
post_processing_worker = PostProcessorWorker(**worker_args)
@@ -1860,6 +1888,7 @@ class DownloadThread(QThread):
if not was_process_cancelled and not self.isInterruptionRequested():
self.logger("✅ All posts processed or end of content reached by DownloadThread.")
except Exception as main_thread_err:
self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}")
traceback.print_exc()

View File

@@ -1,8 +1,5 @@
# --- Standard Library Imports ---
import os
import sys
# --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon
_app_icon_cache = None

View File

@@ -1,18 +1,10 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object
# --- Constants for Dialog Choices ---
# These were moved from main.py to be self-contained within this module's context.
CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
self.parent_app = parent_app
self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list
# Default choice if the dialog is closed without a button press
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350
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)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
self.names_list_widget = QListWidget()
self._populate_list()
main_layout.addWidget(self.names_list_widget)
# --- Selection Buttons ---
selection_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
@@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog):
selection_buttons_layout.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout)
# --- Action Buttons ---
buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected)
@@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog):
sensible default if no items are selected but the "Add" button is clicked.
"""
super().exec_()
# If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice

View File

@@ -1,14 +1,9 @@
# --- Standard Library Imports ---
from collections import defaultdict
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
@@ -18,8 +13,6 @@ class DownloadExtractedLinksDialog(QDialog):
A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox.
"""
# Signal emitted with a list of selected link information dictionaries
download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None):
@@ -34,23 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
super().__init__(parent)
self.links_data = links_data
self.parent_app = parent_app
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if not app_icon.isNull():
self.setWindowIcon(app_icon)
# --- START OF FIX ---
# Get the user-defined scale factor from the parent application.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base dimensions and apply the correct scale factor.
base_width, base_height = 600, 450
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
@@ -68,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list()
layout.addWidget(self.links_list_widget)
# --- Control Buttons ---
button_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
@@ -100,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog):
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles:
# Add a non-selectable header for each post
header_item = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags)
font = header_item.font()
@@ -108,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
font.setPointSize(font.pointSize() + 1)
header_item.setFont(font)
self.links_list_widget.addItem(header_item)
# Add checkable items for each link within that post
for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
@@ -139,19 +117,13 @@ class DownloadExtractedLinksDialog(QDialog):
is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
if is_dark_theme:
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
# Set header text color based on theme
header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
# Headers are not checkable (they have no checkable flag)
if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color)

View File

@@ -1,16 +1,12 @@
# --- Standard Library Imports ---
import os
import time
import json
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
@@ -25,17 +21,14 @@ class DownloadHistoryDialog (QDialog ):
self .first_processed_entries =first_processed_entries
self .setModal (True )
self._apply_theme()
# Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available
creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
if creator_name_cache:
# Patch left pane (files)
for entry in self.last_3_downloaded_entries:
if not entry.get('creator_display_name'):
service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', ''))
key = (service, user_id)
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
# Patch right pane (posts)
for entry in self.first_processed_entries:
if not entry.get('creator_name'):
service = entry.get('service', '').lower()

View File

@@ -42,11 +42,15 @@ class ErrorFilesDialog(QDialog):
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# --- START OF FIX ---
# Get the user-defined scale factor from the parent application.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base dimensions and apply the correct scale factor.
base_width, base_height = 550, 400
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming ---
self._init_ui()

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import html
import os
import sys
@@ -8,8 +7,6 @@ import traceback
import json
import re
from collections import defaultdict
# --- Third-Party Library Imports ---
import requests
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
from PyQt5.QtWidgets import (
@@ -17,12 +14,9 @@ from PyQt5.QtWidgets import (
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
QWidget, QCheckBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request
# Corrected Import: Import CookieHelpDialog directly from its own module
from .CookieHelpDialog import CookieHelpDialog
from ...core.api_client import download_from_api
from ...utils.resolution import get_dark_theme

View File

@@ -1,16 +1,11 @@
# --- Standard Library Imports ---
import os
import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme

View File

@@ -1,13 +1,8 @@
# KeepDuplicatesDialog.py
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
@@ -25,8 +20,6 @@ class KeepDuplicatesDialog(QDialog):
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self)
# Set the initial state based on current settings
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
@@ -44,13 +37,9 @@ class KeepDuplicatesDialog(QDialog):
options_group = QGroupBox()
options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self)
# --- Skip by Hash Option ---
self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash)
# --- Keep Everything Option with Limit Input ---
keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything)
@@ -66,8 +55,6 @@ class KeepDuplicatesDialog(QDialog):
options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group)
# --- OK and Cancel buttons ---
button_layout = QHBoxLayout()
self.ok_button = QPushButton()
self.cancel_button = QPushButton()
@@ -75,8 +62,6 @@ class KeepDuplicatesDialog(QDialog):
button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
# --- Connections ---
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)

View File

@@ -37,12 +37,16 @@ class KnownNamesFilterDialog(QDialog):
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_geometry = QApplication.primaryScreen().availableGeometry()
# --- START OF FIX ---
# Get the user-defined scale factor from the parent application
# instead of calculating an independent one.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base size and apply the correct scale factor
base_width, base_height = 460, 450
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming ---
self._init_ui()

View File

@@ -1,34 +1,33 @@
# SinglePDF.py
import os
import re
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
except ImportError:
FPDF_AVAILABLE = False
def strip_html_tags(text):
if not text:
return ""
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
class PDF(FPDF):
"""Custom PDF class to handle headers and footers."""
def header(self):
# No header
pass
pass
def footer(self):
# Position at 1.5 cm from bottom
self.set_y(-15)
self.set_font('DejaVu', '', 8)
# Page number
if self.font_family:
self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
"""
Creates a single PDF from a list of post titles and content.
Args:
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
output_filename (str): The full path for the output PDF file.
font_path (str): Path to the DejaVuSans.ttf font file.
logger (function, optional): A function to log progress and errors. Defaults to print.
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
"""
if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
@@ -39,34 +38,66 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
return False
pdf = PDF()
default_font_family = 'DejaVu'
bold_font_path = ""
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try:
if not os.path.exists(font_path):
raise RuntimeError("Font file not found.")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
logger(" PDF may not support all characters. Falling back to default Arial font.")
pdf.set_font('Arial', '', 12)
pdf.set_font('Arial', 'B', 16)
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
for post in posts_data:
pdf.add_page()
# Post Title
pdf.set_font('DejaVu', 'B', 16)
# vvv THIS LINE IS CORRECTED vvv
# We explicitly set align='L' and remove the incorrect positional arguments.
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.ln(5) # Add a little space after the title
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
default_font_family = 'Arial'
pdf.add_page()
# Post Content
pdf.set_font('DejaVu', '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
for i, post in enumerate(posts_data):
if i > 0:
if 'content' in post:
pdf.add_page()
elif 'comments' in post:
pdf.ln(10)
pdf.cell(0, 0, '', border='T')
pdf.ln(10)
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
if 'comments' in post and post['comments']:
comments_list = post['comments']
for comment_index, comment in enumerate(comments_list):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
if user is not None:
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if comment_index < len(comments_list) - 1:
pdf.ln(3)
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
elif 'content' in post:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
try:
pdf.output(output_filename)

View File

@@ -1,15 +1,10 @@
# --- Standard Library Imports ---
import os
import sys
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
@@ -58,8 +53,6 @@ class TourDialog(QDialog):
"""
tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal()
# Constants for QSettings
CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19"
@@ -98,8 +91,6 @@ class TourDialog(QDialog):
self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1)
# Load content for each step
steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"),
@@ -120,8 +111,6 @@ class TourDialog(QDialog):
self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
# --- Bottom Controls ---
bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12)

View File

@@ -1,4 +1,3 @@
# --- Standard Library Imports ---
import sys
import os
import time
@@ -16,8 +15,6 @@ from collections import deque, defaultdict
import threading
from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError
from urllib .parse import urlparse
# --- PyQt5 Imports ---
from PyQt5.QtGui import QIcon, QIntValidator, QDesktopServices
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
@@ -27,8 +24,6 @@ from PyQt5.QtWidgets import (
QMainWindow, QAction, QGridLayout,
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker, QCoreApplication
# --- Local Application Imports ---
from ..services.drive_downloader import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file
from ..core.workers import DownloadThread as BackendDownloadThread
from ..core.workers import PostProcessorWorker
@@ -137,8 +132,6 @@ class DownloaderApp (QWidget ):
self.creator_name_cache = {}
self.log_signal.emit(f" App base directory: {self.app_base_dir}")
self.log_signal.emit(f" Persistent history file path set to: {self.persistent_history_file}")
# --- The rest of your __init__ method continues from here ---
self.last_downloaded_files_details = deque(maxlen=3)
self.download_history_candidates = deque(maxlen=8)
self.final_download_history_entries = []
@@ -225,7 +218,7 @@ class DownloaderApp (QWidget ):
self.text_export_format = 'pdf'
self.single_pdf_setting = False
self.keep_duplicates_mode = DUPLICATE_HANDLING_HASH
self.keep_duplicates_limit = 0 # 0 means no limit
self.keep_duplicates_limit = 0
self.downloaded_hash_counts = defaultdict(int)
self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = []
@@ -288,8 +281,6 @@ class DownloaderApp (QWidget ):
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")
# Prompt for restart
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("theme_change_title", "Theme Changed"))
@@ -447,14 +438,14 @@ class DownloaderApp (QWidget ):
self._load_ui_from_settings_dict(settings)
self.is_restore_pending = True
self._update_button_states_and_connections() # Update buttons for restore state, UI remains editable
self._update_button_states_and_connections()
def _clear_session_and_reset_ui(self):
"""Clears the session file and resets the UI to its default state."""
self._clear_session_file()
self.interrupted_session_data = None
self.is_restore_pending = False
self._update_button_states_and_connections() # Ensure buttons are updated to idle state
self._update_button_states_and_connections()
self.reset_application_state()
def _clear_session_file(self):
@@ -489,7 +480,6 @@ class DownloaderApp (QWidget ):
Updates the text and click connections of the main action buttons
based on the current application state (downloading, paused, restore pending, idle).
"""
# Disconnect all signals first to prevent multiple connections
try: self.download_btn.clicked.disconnect()
except TypeError: pass
try: self.pause_btn.clicked.disconnect()
@@ -500,7 +490,6 @@ class DownloaderApp (QWidget ):
is_download_active = self._is_download_active()
if self.is_restore_pending:
# State: Restore Pending
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
@@ -511,14 +500,12 @@ class DownloaderApp (QWidget ):
self.pause_btn.clicked.connect(self.restore_download)
self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download."))
# --- START: CORRECTED CANCEL BUTTON LOGIC ---
self.cancel_btn.setText(self._tr("discard_session_button_text", "🗑️ Discard Session"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_session_and_reset_ui)
self.cancel_btn.setToolTip(self._tr("discard_session_tooltip", "Click to discard the interrupted session and reset the UI."))
elif is_download_active:
# State: Downloading / Paused
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(False) # Cannot start new download while one is active
@@ -532,7 +519,6 @@ class DownloaderApp (QWidget ):
self.cancel_btn.clicked.connect(self.cancel_download_button_action)
self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
else:
# State: Idle (No download, no restore pending)
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
@@ -870,7 +856,7 @@ class DownloaderApp (QWidget ):
self ._handle_actual_file_downloaded (payload [0 ]if payload else {})
elif signal_type =='file_successfully_downloaded':
self ._handle_file_successfully_downloaded (payload [0 ])
elif signal_type == 'worker_finished': # <-- ADD THIS ELIF BLOCK
elif signal_type == 'worker_finished':
self.actual_gui_signals.worker_finished_signal.emit(payload[0] if payload else tuple())
else:
self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }")
@@ -1344,13 +1330,7 @@ class DownloaderApp (QWidget ):
def _show_future_settings_dialog(self):
"""Shows the placeholder dialog for future settings."""
# --- DEBUGGING CODE TO FIND THE UNEXPECTED CALL ---
import traceback
print("--- DEBUG: _show_future_settings_dialog() was called. See stack trace below. ---")
traceback.print_stack()
print("--------------------------------------------------------------------------------")
# Correctly create the dialog instance once with the parent set to self.
dialog = FutureSettingsDialog(self)
dialog.exec_()
@@ -1364,7 +1344,6 @@ class DownloaderApp (QWidget ):
Checks if the fetcher thread is done AND if all submitted tasks have been processed.
If so, finalizes the download.
"""
# Conditions for being completely finished:
fetcher_is_done = not self.is_fetcher_thread_running
all_workers_are_done = (self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process)
@@ -1643,24 +1622,15 @@ class DownloaderApp (QWidget ):
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode:
# Check if this is a new post title
if post_title != self._current_link_post_title:
# Add a styled horizontal rule as a separator
if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html)
# Display the new post title as a styled heading
title_html = f'{HTML_PREFIX}<h3 style="color: #87CEEB; margin-bottom: 5px; margin-top: 8px;">{html.escape(post_title)}</h3>'
self.log_signal.emit(title_html)
self._current_link_post_title = post_title
# Sanitize the link text for safe HTML display
display_text = html.escape(link_text.strip() if link_text.strip() else link_url)
# Build the HTML for the link item for a cleaner look
link_html_parts = [
# Use a div for indentation and a bullet point for list-like appearance
f'<div style="margin-left: 20px; margin-bottom: 4px;">'
f'• <a href="{link_url}" style="color: #A9D0F5; text-decoration: none;">{display_text}</a>'
f' <span style="color: #999;">({html.escape(platform)})</span>'
@@ -1668,7 +1638,6 @@ class DownloaderApp (QWidget ):
if decryption_key:
link_html_parts.append(
# Display key on a new line, indented, and in a different color
f'<br><span style="margin-left: 15px; color: #f0ad4e; font-size: 9pt;">'
f'Key: {html.escape(decryption_key)}</span>'
)
@@ -1677,8 +1646,6 @@ class DownloaderApp (QWidget ):
final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}'
self.log_signal.emit(final_link_html)
# This part handles the secondary log panel and remains the same
elif self .show_external_links :
separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}"
@@ -1818,22 +1785,13 @@ class DownloaderApp (QWidget ):
def _handle_filter_mode_change(self, button, checked):
if not button or not checked:
return
# Define this variable early to ensure it's always available.
is_only_links = (button == self.radio_only_links)
# Handle the automatic disabling of multithreading for link extraction
if hasattr(self, 'use_multithreading_checkbox'):
if is_only_links:
# Disable multithreading for "Only Links" to avoid the bug
self.use_multithreading_checkbox.setChecked(False)
self.use_multithreading_checkbox.setEnabled(False)
else:
# Re-enable the multithreading option for other modes.
# Other logic will handle disabling it if needed (e.g., for Manga Date mode).
self.use_multithreading_checkbox.setEnabled(True)
# Reset the "More" button text if another button is selected
if button != self.radio_more and checked:
self.radio_more.setText("More")
self.more_filter_scope = None
@@ -2257,8 +2215,6 @@ class DownloaderApp (QWidget ):
def _handle_more_options_toggled(self, button, checked):
"""Shows the MoreOptionsDialog when the 'More' radio button is selected."""
# This block handles when the user clicks ON the "More" button.
if button == self.radio_more and checked:
current_scope = self.more_filter_scope or MoreOptionsDialog.SCOPE_CONTENT
current_format = self.text_export_format or 'pdf'
@@ -2274,26 +2230,17 @@ class DownloaderApp (QWidget ):
self.more_filter_scope = dialog.get_selected_scope()
self.text_export_format = dialog.get_selected_format()
self.single_pdf_setting = dialog.get_single_pdf_state()
# Define the variable based on the dialog's result
is_any_pdf_mode = (self.text_export_format == 'pdf')
# Update the radio button text to reflect the choice
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
format_display = f" ({self.text_export_format.upper()})"
if self.single_pdf_setting:
format_display = " (Single PDF)"
self.radio_more.setText(f"{scope_text}{format_display}")
# --- Logic to Disable/Enable Checkboxes ---
# Disable multithreading for ANY PDF export
if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(not is_any_pdf_mode)
if is_any_pdf_mode:
self.use_multithreading_checkbox.setChecked(False)
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
# Also disable subfolders for the "Single PDF" case, as it doesn't apply
if hasattr(self, 'use_subfolders_checkbox'):
self.use_subfolders_checkbox.setEnabled(not self.single_pdf_setting)
if self.single_pdf_setting:
@@ -2304,16 +2251,12 @@ class DownloaderApp (QWidget ):
if is_any_pdf_mode:
self.log_signal.emit(" Multithreading automatically disabled for PDF export.")
else:
# User cancelled the dialog, so revert to the 'All' option.
self.log_signal.emit(" 'More' filter selection cancelled. Reverting to 'All'.")
self.radio_all.setChecked(True)
# This block handles when the user switches AWAY from "More" to another option.
elif button != self.radio_more and checked:
self.radio_more.setText("More")
self.more_filter_scope = None
self.single_pdf_setting = False
# Re-enable the checkboxes when switching to any non-PDF mode
if hasattr(self, 'use_multithreading_checkbox'):
self.use_multithreading_checkbox.setEnabled(True)
self._update_multithreading_for_date_mode()
@@ -2383,7 +2326,6 @@ class DownloaderApp (QWidget ):
self .use_subfolder_per_post_checkbox .setChecked (False )
if hasattr(self, 'date_prefix_checkbox'):
# The Date Prefix checkbox should only be enabled if "Subfolder per Post" is both enabled and checked
can_enable_date_prefix = self.use_subfolder_per_post_checkbox.isEnabled() and self.use_subfolder_per_post_checkbox.isChecked()
self.date_prefix_checkbox.setEnabled(can_enable_date_prefix)
if not can_enable_date_prefix:
@@ -3435,7 +3377,6 @@ class DownloaderApp (QWidget ):
def _load_ui_from_settings_dict(self, settings: dict):
"""Populates the UI with values from a settings dictionary."""
# Text inputs
self.link_input.setText(settings.get('api_url', ''))
self.dir_input.setText(settings.get('output_dir', ''))
self.character_input.setText(settings.get('character_filter_text', ''))
@@ -3445,19 +3386,13 @@ class DownloaderApp (QWidget ):
self.cookie_text_input.setText(settings.get('cookie_text', ''))
if hasattr(self, 'manga_date_prefix_input'):
self.manga_date_prefix_input.setText(settings.get('manga_date_prefix', ''))
# Numeric inputs
self.thread_count_input.setText(str(settings.get('num_threads', 4)))
self.start_page_input.setText(str(settings.get('start_page', '')) if settings.get('start_page') is not None else '')
self.end_page_input.setText(str(settings.get('end_page', '')) if settings.get('end_page') is not None else '')
# Checkboxes
for checkbox_name, key in self.get_checkbox_map().items():
checkbox = getattr(self, checkbox_name, None)
if checkbox:
checkbox.setChecked(settings.get(key, False))
# Radio buttons
if settings.get('only_links'): self.radio_only_links.setChecked(True)
else:
filter_mode = settings.get('filter_mode', 'all')
@@ -3469,17 +3404,12 @@ class DownloaderApp (QWidget ):
self.keep_duplicates_mode = settings.get('keep_duplicates_mode', DUPLICATE_HANDLING_HASH)
self.keep_duplicates_limit = settings.get('keep_duplicates_limit', 0)
# Visually update the checkbox based on the restored mode
if hasattr(self, 'keep_duplicates_checkbox'):
is_keep_mode = (self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL)
self.keep_duplicates_checkbox.setChecked(is_keep_mode)
# Restore "More" dialog settings
self.more_filter_scope = settings.get('more_filter_scope')
self.text_export_format = settings.get('text_export_format', 'pdf')
self.single_pdf_setting = settings.get('single_pdf_setting', False)
# Visually update the "More" button's text to reflect the restored settings
if self.radio_more.isChecked() and self.more_filter_scope:
from .dialogs.MoreOptionsDialog import MoreOptionsDialog
scope_text = "Comments" if self.more_filter_scope == MoreOptionsDialog.SCOPE_COMMENTS else "Description"
@@ -3487,14 +3417,10 @@ class DownloaderApp (QWidget ):
if self.single_pdf_setting:
format_display = " (Single PDF)"
self.radio_more.setText(f"{scope_text}{format_display}")
# Toggle button states
self.skip_words_scope = settings.get('skip_words_scope', SKIP_SCOPE_POSTS)
self.char_filter_scope = settings.get('char_filter_scope', CHAR_SCOPE_TITLE)
self.manga_filename_style = settings.get('manga_filename_style', STYLE_POST_TITLE)
self.allow_multipart_download_setting = settings.get('allow_multipart_download', False)
# Update button texts after setting states
self._update_skip_scope_button_text()
self._update_char_filter_scope_button_text()
self._update_manga_filename_style_button_text()
@@ -3541,17 +3467,15 @@ class DownloaderApp (QWidget ):
in multi-threaded mode.
"""
global PostProcessorWorker, download_from_api, requests, json, traceback, urlparse
# Unpack arguments from the dictionary passed by the thread
api_url_input_for_fetcher = fetcher_args['api_url']
worker_args_template = fetcher_args['worker_args_template']
processed_post_ids_set = set(fetcher_args.get('processed_post_ids', []))
start_offset = fetcher_args.get('start_offset', 0)
start_page = worker_args_template.get('start_page')
end_page = worker_args_template.get('end_page')
target_post_id = worker_args_template.get('target_post_id_from_initial_url') # Get the target post ID
logger_func = lambda msg: self.log_signal.emit(f"[Fetcher] {msg}")
try:
# Prepare common variables for the fetcher thread
service = worker_args_template.get('service')
user_id = worker_args_template.get('user_id')
cancellation_event = self.cancellation_event
@@ -3582,8 +3506,6 @@ class DownloaderApp (QWidget ):
if not isinstance(single_post_data, dict):
raise ValueError(f"Expected a dictionary for post data, but got {type(single_post_data)}")
# Set total posts to 1 and submit the single job to the worker pool
self.total_posts_to_process = 1
self.overall_progress_signal.emit(1, 0)
@@ -3600,8 +3522,12 @@ class DownloaderApp (QWidget ):
logger_func(f"❌ Failed to fetch single post directly: {e}. Aborting.")
return
offset = start_offset
page_size = 50
offset = 0
current_page_num = 1
if start_page and start_page > 1:
offset = (start_page - 1) * page_size
current_page_num = start_page
while not cancellation_event.is_set():
while pause_event.is_set():
@@ -3609,6 +3535,10 @@ class DownloaderApp (QWidget ):
if cancellation_event.is_set(): break
if cancellation_event.is_set(): break
if end_page and current_page_num > end_page:
logger_func(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
break
api_url = f"https://{parsed_api_url.netloc}/api/v1/{service}/user/{user_id}?o={offset}"
logger_func(f"Fetching post list: {api_url} (Page approx. {offset // page_size + 1})")
@@ -3617,7 +3547,8 @@ class DownloaderApp (QWidget ):
response.raise_for_status()
posts_batch_from_api = response.json()
except (requests.RequestException, json.JSONDecodeError) as e:
logger_func(f"❌ API Error fetching posts: {e}. Stopping fetch.")
logger_func(f"❌ API Error fetching posts: {e}. Aborting the entire download.")
self.cancellation_event.set()
break
if not posts_batch_from_api:
@@ -3656,7 +3587,8 @@ class DownloaderApp (QWidget ):
except (json.JSONDecodeError, KeyError, OSError) as e:
logger_func(f"⚠️ Could not update session offset: {e}")
offset = next_offset
offset = offset + page_size
current_page_num += 1
except Exception as e:
logger_func(f"❌ Critical error during post fetching: {e}\n{traceback.format_exc(limit=2)}")
@@ -3686,8 +3618,6 @@ class DownloaderApp (QWidget ):
if permanent:
self.permanently_failed_files_for_dialog.extend(permanent)
self._update_error_button_count()
# Other result handling
if history_data: self._add_to_history_candidates(history_data)
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
@@ -4023,7 +3953,7 @@ class DownloaderApp (QWidget ):
self.is_finishing = True
if not self .cancel_btn .isEnabled ()and not self .cancellation_event .is_set ():self .log_signal .emit (" No active download to cancel or already cancelling.");return
self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...")
self._cleanup_temp_files()
self._clear_session_file() # Clear session file on explicit cancel
if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
self .log_signal .emit (" Cancelling active External Link download thread...")
@@ -4205,8 +4135,6 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(f" Duplicate handling mode set to: '{self.keep_duplicates_mode}' {limit_text}.")
self.log_signal.emit(f"")
self.log_signal.emit(f"")
# Log warning only after the confirmation and only if the specific mode is selected
if self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL:
self._log_keep_everything_warning()
else:
@@ -4394,14 +4322,11 @@ class DownloaderApp (QWidget ):
if os.path.exists(self.session_file_path):
try:
with self.session_lock:
# Read the current session data
with open(self.session_file_path, 'r', encoding='utf-8') as f:
session_data = json.load(f)
if 'download_state' in session_data:
session_data['download_state']['permanently_failed_files'] = self.permanently_failed_files_for_dialog
# Save the updated session data back to the file
self._save_session_file(session_data)
self.log_signal.emit(" Session file updated with retry results.")
@@ -4457,23 +4382,17 @@ class DownloaderApp (QWidget ):
self.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.")
self.download_thread.deleteLater()
self.download_thread = None
# Try to cancel thread pool
if self.thread_pool:
self.log_signal.emit(" Shutting down thread pool for reset...")
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.thread_pool = None
self.active_futures = []
# Try to cancel external link download thread
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.log_signal.emit(" Cancelling external link download thread for reset...")
self.external_link_download_thread.cancel()
self.external_link_download_thread.wait(3000)
self.external_link_download_thread.deleteLater()
self.external_link_download_thread = None
# Try to cancel retry thread pool
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Shutting down retry thread pool for reset...")
self.retry_thread_pool.shutdown(wait=True)
@@ -4494,9 +4413,6 @@ class DownloaderApp (QWidget ):
self._load_saved_download_location()
self.main_log_output.clear()
self.external_log_output.clear()
# --- Reset UI and all state ---
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._reset_ui_to_defaults()
self._load_saved_download_location()
@@ -4513,8 +4429,6 @@ class DownloaderApp (QWidget ):
if self.log_verbosity_toggle_button:
self.log_verbosity_toggle_button.setText(self.EYE_ICON)
self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.")
# Clear all download-related state
self.external_link_queue.clear()
self.extracted_links_cache = []
self._is_processing_external_link_queue = False
@@ -4564,11 +4478,9 @@ class DownloaderApp (QWidget ):
self.interrupted_session_data = None
self.is_restore_pending = False
self.last_link_input_text_for_queue_sync = ""
# Replace your current reset_application_state with the above.
def _reset_ui_to_defaults(self):
"""Resets all UI elements and relevant state to their default values."""
# Clear all text fields
self.link_input.clear()
self.custom_folder_input.clear()
self.character_input.clear()
@@ -4582,8 +4494,6 @@ class DownloaderApp (QWidget ):
self.thread_count_input.setText("4")
if hasattr(self, 'manga_date_prefix_input'):
self.manga_date_prefix_input.clear()
# Set radio buttons and checkboxes to defaults
self.radio_all.setChecked(True)
self.skip_zip_checkbox.setChecked(True)
self.download_thumbnails_checkbox.setChecked(False)
@@ -4605,8 +4515,6 @@ class DownloaderApp (QWidget ):
self.selected_cookie_filepath = None
if hasattr(self, 'cookie_text_input'):
self.cookie_text_input.clear()
# Reset log and progress displays
if self.main_log_output:
self.main_log_output.clear()
if self.external_log_output:
@@ -4615,8 +4523,6 @@ class DownloaderApp (QWidget ):
self.missed_character_log_output.clear()
self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle"))
self.file_progress_label.setText("")
# Reset internal state
self.missed_title_key_terms_count.clear()
self.missed_title_key_terms_examples.clear()
self.logged_summary_for_key_term.clear()
@@ -4647,8 +4553,6 @@ class DownloaderApp (QWidget ):
self._current_link_post_title = None
if self.download_extracted_links_button:
self.download_extracted_links_button.setEnabled(False)
# Reset favorite/queue/session state
self.favorite_download_queue.clear()
self.is_processing_favorites_queue = False
self.current_processing_favorite_item_info = None
@@ -4656,14 +4560,11 @@ class DownloaderApp (QWidget ):
self.is_restore_pending = False
self.last_link_input_text_for_queue_sync = ""
self._update_button_states_and_connections()
# Reset counters and progress
self.total_posts_to_process = 0
self.processed_posts_count = 0
self.download_counter = 0
self.skip_counter = 0
self.all_kept_original_filenames = []
# Reset log view and UI state
if self.log_view_stack:
self.log_view_stack.setCurrentIndex(0)
if self.progress_log_label:
@@ -4671,27 +4572,19 @@ class DownloaderApp (QWidget ):
if self.log_verbosity_toggle_button:
self.log_verbosity_toggle_button.setText(self.EYE_ICON)
self.log_verbosity_toggle_button.setToolTip("Current View: Progress Log. Click to switch to Missed Character Log.")
# Reset character list filter
self.filter_character_list("")
# Update UI for manga mode and multithreading
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
self.update_ui_for_manga_mode(False)
self.update_custom_folder_visibility(self.link_input.text())
self.update_page_range_enabled_state()
self._update_cookie_input_visibility(False)
self._update_cookie_input_placeholders_and_tooltips()
# Reset button states
self.download_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
if self.reset_button:
self.reset_button.setEnabled(True)
self.reset_button.setText(self._tr("reset_button_text", "🔄 Reset"))
self.reset_button.setToolTip(self._tr("reset_button_tooltip", "Reset all inputs and logs to default state (only when idle)."))
# Reset favorite mode UI
if hasattr(self, 'favorite_mode_checkbox'):
self._handle_favorite_mode_toggle(False)
if hasattr(self, 'scan_content_images_checkbox'):
@@ -4887,8 +4780,6 @@ class DownloaderApp (QWidget ):
self._tr("restore_pending_message_creator_selection",
"Please 'Restore Download' or 'Discard Session' before selecting new creators."))
return
# Correctly create the dialog instance
dialog = EmptyPopupDialog(self.app_base_dir, self)
if dialog.exec_() == QDialog.Accepted:
if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: