2025-05-10 11:07:27 +05:30

2237 lines
147 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sys
import os
import time
import requests
import re
import threading
import queue # Standard library queue, not directly used for the new link queue
import hashlib
import http.client
import traceback
import random # <-- Import random for generating delays
from collections import deque # <-- Import deque for the link queue
from concurrent.futures import ThreadPoolExecutor, CancelledError, Future
from PyQt5.QtGui import (
QIcon,
QIntValidator
)
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget,
QRadioButton, QButtonGroup, QCheckBox, QSplitter, QSizePolicy, QDialog,
QFrame,
QAbstractButton
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer, QSettings
from urllib.parse import urlparse
try:
from PIL import Image
except ImportError:
Image = None
from io import BytesIO
# --- Import from downloader_utils ---
try:
print("Attempting to import from downloader_utils...")
from downloader_utils import (
KNOWN_NAMES,
clean_folder_name,
extract_post_info,
download_from_api,
PostProcessorSignals,
PostProcessorWorker,
DownloadThread as BackendDownloadThread, # Renamed to avoid conflict
SKIP_SCOPE_FILES,
SKIP_SCOPE_POSTS,
SKIP_SCOPE_BOTH
)
print("Successfully imported names from downloader_utils.")
except ImportError as e:
print(f"--- IMPORT ERROR ---")
print(f"Failed to import from 'downloader_utils.py': {e}")
# Define fallbacks if import fails, so the app might still run with limited functionality or show an error.
KNOWN_NAMES = []
PostProcessorSignals = QObject # Fallback to base QObject
PostProcessorWorker = object # Fallback to base object
BackendDownloadThread = QThread # Fallback to base QThread
def clean_folder_name(n): return str(n) # Simple fallback
def extract_post_info(u): return None, None, None # Fallback
def download_from_api(*a, **k): yield [] # Fallback generator
SKIP_SCOPE_FILES = "files"
SKIP_SCOPE_POSTS = "posts"
SKIP_SCOPE_BOTH = "both"
# Potentially show a critical error to the user here if downloader_utils is essential
# For now, printing to console is the primary error indication.
except Exception as e:
print(f"--- UNEXPECTED IMPORT ERROR ---")
print(f"An unexpected error occurred during import: {e}")
traceback.print_exc()
print(f"-----------------------------", file=sys.stderr)
sys.exit(1) # Exit if a critical, unexpected error occurs during import
# --- End Import ---
# --- Import Tour Dialog ---
try:
from tour import TourDialog # Assuming tour.py exists in the same directory
print("Successfully imported TourDialog from tour.py.")
except ImportError as e:
print(f"--- TOUR IMPORT ERROR ---")
print(f"Failed to import TourDialog from 'tour.py': {e}")
print("Tour functionality will be unavailable.")
TourDialog = None # Fallback if tour.py is not found
except Exception as e:
print(f"--- UNEXPECTED TOUR IMPORT ERROR ---")
print(f"An unexpected error occurred during tour import: {e}")
traceback.print_exc()
TourDialog = None
# --- End Tour Import ---
# --- Constants for Thread Limits ---
MAX_THREADS = 200 # Max post workers for creator feeds
RECOMMENDED_MAX_THREADS = 50 # Recommended max post workers
MAX_FILE_THREADS_PER_POST_OR_WORKER = 10 # Max file download threads for single post or per creator feed worker
# --- END ---
HTML_PREFIX = "<!HTML!>" # Prefix to indicate a log message is HTML
# --- QSettings Constants ---
CONFIG_ORGANIZATION_NAME = "KemonoDownloader" # Company/Organization Name for settings
CONFIG_APP_NAME_MAIN = "ApplicationSettings" # Application Name for settings
MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1" # Key for storing manga filename style
STYLE_POST_TITLE = "post_title" # Constant for post title filename style
STYLE_ORIGINAL_NAME = "original_name" # Constant for original filename style
SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1" # Key for storing skip words scope
# --- END QSettings ---
class DownloaderApp(QWidget):
# Signals for cross-thread communication and UI updates
character_prompt_response_signal = pyqtSignal(bool) # Signal for character prompt response
log_signal = pyqtSignal(str) # Signal for logging messages to the UI
add_character_prompt_signal = pyqtSignal(str) # Signal to prompt adding a character
overall_progress_signal = pyqtSignal(int, int) # Signal for overall download progress (total, processed)
finished_signal = pyqtSignal(int, int, bool, list) # Signal when download finishes (dl_count, skip_count, cancelled, kept_original_names)
external_link_signal = pyqtSignal(str, str, str, str) # Signal for found external links (post_title, link_text, url, platform)
file_progress_signal = pyqtSignal(str, int, int) # Signal for individual file download progress (filename, downloaded_bytes, total_bytes)
def __init__(self):
super().__init__()
# Initialize QSettings for storing application settings persistently
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
self.config_file = "Known.txt" # File to store known character/show names
# Download process related attributes
self.download_thread = None # Holds the single download thread instance
self.thread_pool = None # Holds the ThreadPoolExecutor for multi-threaded downloads
self.cancellation_event = threading.Event() # Event to signal cancellation to threads
self.active_futures = [] # List of active Future objects from the thread pool
self.total_posts_to_process = 0 # Total posts identified for the current download
self.processed_posts_count = 0 # Number of posts processed so far
self.download_counter = 0 # Total files downloaded in the current session/run
self.skip_counter = 0 # Total files skipped in the current session/run
# Signals object for PostProcessorWorker instances
self.worker_signals = PostProcessorSignals()
# Mutex and response attribute for synchronous character add prompt
self.prompt_mutex = QMutex()
self._add_character_response = None
# Sets to keep track of downloaded files/hashes to avoid re-downloads in the same session
self.downloaded_files = set() # Set of downloaded filenames (final saved names)
self.downloaded_files_lock = threading.Lock() # Lock for accessing downloaded_files set
self.downloaded_file_hashes = set() # Set of MD5 hashes of downloaded files
self.downloaded_file_hashes_lock = threading.Lock() # Lock for accessing downloaded_file_hashes set
# External links related attributes
self.show_external_links = False # Flag to control display of external links log
self.external_link_queue = deque() # Queue for processing external links with delays
self._is_processing_external_link_queue = False # Flag to prevent concurrent processing of the link queue
self._current_link_post_title = None # Tracks current post title for grouping links in "Only Links" mode
self.extracted_links_cache = [] # Cache of all extracted links for "Only Links" mode display and export
# UI and Logging related attributes
self.basic_log_mode = False # Flag for toggling basic/full log verbosity
self.log_verbosity_button = None # Button to toggle log verbosity
self.manga_rename_toggle_button = None # Button to toggle manga filename style
self.main_log_output = None # QTextEdit for main progress log
self.external_log_output = None # QTextEdit for external links log
self.log_splitter = None # QSplitter for main and external logs
self.main_splitter = None # Main QSplitter for left (controls) and right (logs) panels
self.reset_button = None # Button to reset application state
self.progress_log_label = None # Label above the main log area
self.link_search_input = None # QLineEdit for searching in extracted links
self.link_search_button = None # QPushButton to trigger link search/filter
self.export_links_button = None # QPushButton to export extracted links
self.manga_mode_checkbox = None # QCheckBox for enabling Manga/Comic mode
self.radio_only_links = None # QRadioButton for "Only Links" filter mode
self.radio_only_archives = None # QRadioButton for "Only Archives" filter mode
self.skip_scope_toggle_button = None # Button to cycle skip words scope
# List to store filenames that kept their original names (for manga mode logging)
self.all_kept_original_filenames = []
# Load persistent settings or use defaults
self.manga_filename_style = self.settings.value(MANGA_FILENAME_STYLE_KEY, STYLE_POST_TITLE, type=str)
self.skip_words_scope = self.settings.value(SKIP_WORDS_SCOPE_KEY, SKIP_SCOPE_FILES, type=str)
self.load_known_names_from_util() # Load known names from config file
self.setWindowTitle("Kemono Downloader v3.1.0") # Update version number
self.setGeometry(150, 150, 1050, 820) # Set initial window size and position
self.setStyleSheet(self.get_dark_theme()) # Apply a dark theme stylesheet
self.init_ui() # Initialize the user interface elements
self._connect_signals() # Connect signals to their respective slots
# Initial log messages
self.log_signal.emit(" Local API server functionality has been removed.")
self.log_signal.emit(" 'Skip Current File' button has been removed.")
if hasattr(self, 'character_input'): # Set tooltip for character input if it exists
self.character_input.setToolTip("Enter one or more character names, separated by commas (e.g., yor, makima)")
self.log_signal.emit(f" Manga filename style loaded: '{self.manga_filename_style}'")
self.log_signal.emit(f" Skip words scope loaded: '{self.skip_words_scope}'")
def _connect_signals(self):
"""Connects various signals from UI elements and worker threads to their handler methods."""
# Worker signals (from PostProcessorWorker via PostProcessorSignals)
if hasattr(self.worker_signals, 'progress_signal'):
self.worker_signals.progress_signal.connect(self.handle_main_log)
if hasattr(self.worker_signals, 'file_progress_signal'):
self.worker_signals.file_progress_signal.connect(self.update_file_progress_display)
if hasattr(self.worker_signals, 'external_link_signal'):
self.worker_signals.external_link_signal.connect(self.handle_external_link_signal)
# Internal app signals
self.log_signal.connect(self.handle_main_log)
self.add_character_prompt_signal.connect(self.prompt_add_character)
self.character_prompt_response_signal.connect(self.receive_add_character_result)
self.overall_progress_signal.connect(self.update_progress_display)
self.finished_signal.connect(self.download_finished)
self.external_link_signal.connect(self.handle_external_link_signal) # Also connect direct app signal
self.file_progress_signal.connect(self.update_file_progress_display) # Also connect direct app signal
# UI element signals
if hasattr(self, 'character_search_input'): self.character_search_input.textChanged.connect(self.filter_character_list)
if hasattr(self, 'external_links_checkbox'): self.external_links_checkbox.toggled.connect(self.update_external_links_setting)
if hasattr(self, 'thread_count_input'): self.thread_count_input.textChanged.connect(self.update_multithreading_label)
if hasattr(self, 'use_subfolder_per_post_checkbox'): self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders)
if hasattr(self, 'use_multithreading_checkbox'): self.use_multithreading_checkbox.toggled.connect(self._handle_multithreading_toggle)
# Radio button group for file filters
if hasattr(self, 'radio_group') and self.radio_group:
# Connect only once to the buttonToggled signal of the QButtonGroup
self.radio_group.buttonToggled.connect(self._handle_filter_mode_change)
# Button clicks
if self.reset_button: self.reset_button.clicked.connect(self.reset_application_state)
if self.log_verbosity_button: self.log_verbosity_button.clicked.connect(self.toggle_log_verbosity)
# Link search UI signals (for "Only Links" mode)
if self.link_search_button: self.link_search_button.clicked.connect(self._filter_links_log)
if self.link_search_input:
self.link_search_input.returnPressed.connect(self._filter_links_log) # Filter on Enter
self.link_search_input.textChanged.connect(self._filter_links_log) # Live filtering as text changes
if self.export_links_button: self.export_links_button.clicked.connect(self._export_links_to_file)
# Manga mode UI signals
if self.manga_mode_checkbox: self.manga_mode_checkbox.toggled.connect(self.update_ui_for_manga_mode)
if self.manga_rename_toggle_button: self.manga_rename_toggle_button.clicked.connect(self._toggle_manga_filename_style)
# URL input text change (affects manga mode UI and page range)
if hasattr(self, 'link_input'):
self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False))
# Skip words scope toggle button
if self.skip_scope_toggle_button:
self.skip_scope_toggle_button.clicked.connect(self._cycle_skip_scope)
def load_known_names_from_util(self):
"""Loads known character/show names from the config file into the global KNOWN_NAMES list."""
global KNOWN_NAMES # Access the global list (potentially shared with downloader_utils)
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
raw_names = [line.strip() for line in f]
# Update KNOWN_NAMES in-place to ensure shared references (like in downloader_utils) are updated
KNOWN_NAMES[:] = sorted(list(set(filter(None, raw_names)))) # Unique, sorted, non-empty names
log_msg = f" Loaded {len(KNOWN_NAMES)} known names from {self.config_file}"
except Exception as e:
log_msg = f"❌ Error loading config '{self.config_file}': {e}"
QMessageBox.warning(self, "Config Load Error", f"Could not load list from {self.config_file}:\n{e}")
KNOWN_NAMES[:] = [] # Reset to empty if loading fails
else:
log_msg = f" Config file '{self.config_file}' not found. Starting empty."
KNOWN_NAMES[:] = [] # Ensure it's empty if file doesn't exist
if hasattr(self, 'log_signal'): self.log_signal.emit(log_msg) # Log loading status
# Update the QListWidget in the UI with the loaded names
if hasattr(self, 'character_list'):
self.character_list.clear()
self.character_list.addItems(KNOWN_NAMES)
def save_known_names(self):
"""Saves the current list of known names to the config file."""
global KNOWN_NAMES # Access the global (potentially shared) list
try:
# Ensure KNOWN_NAMES itself is updated to the unique sorted list before saving
unique_sorted_names = sorted(list(set(filter(None, KNOWN_NAMES))))
KNOWN_NAMES[:] = unique_sorted_names # Modify in-place
with open(self.config_file, 'w', encoding='utf-8') as f:
for name in unique_sorted_names:
f.write(name + '\n')
if hasattr(self, 'log_signal'): self.log_signal.emit(f"💾 Saved {len(unique_sorted_names)} known names to {self.config_file}")
except Exception as e:
log_msg = f"❌ Error saving config '{self.config_file}': {e}"
if hasattr(self, 'log_signal'): self.log_signal.emit(log_msg)
QMessageBox.warning(self, "Config Save Error", f"Could not save list to {self.config_file}:\n{e}")
def closeEvent(self, event):
"""Handles the application close event. Saves settings and manages active downloads."""
# Save known names and other persistent settings
self.save_known_names()
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
self.settings.sync() # Ensure settings are written to disk
should_exit = True
is_downloading = self._is_download_active() # Check if any download is currently active
if is_downloading:
# Confirm with the user if they want to exit while a download is in progress
reply = QMessageBox.question(self, "Confirm Exit",
"Download in progress. Are you sure you want to exit and cancel?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No) # Default to No
if reply == QMessageBox.Yes:
self.log_signal.emit("⚠️ Cancelling active download due to application exit...")
self.cancel_download() # Signal cancellation to active threads/pool
self.log_signal.emit(" Waiting briefly for threads to acknowledge cancellation...")
# Wait for threads to finish, with a timeout
if self.download_thread and self.download_thread.isRunning():
self.download_thread.wait(3000) # Wait up to 3 seconds for single thread
if self.download_thread.isRunning():
self.log_signal.emit(" ⚠️ Single download thread did not terminate gracefully.")
if self.thread_pool:
# Shutdown with cancel_futures=True. The wait=True here might block,
# but cancel_download should have already signaled futures.
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.log_signal.emit(" Thread pool shutdown complete.")
self.thread_pool = None # Clear the reference
else:
should_exit = False # User chose not to exit
self.log_signal.emit(" Application exit cancelled.")
event.ignore() # Ignore the close event
return # Don't proceed to exit
if should_exit:
self.log_signal.emit(" Application closing.")
# Ensure any remaining pool is shut down if not already handled
if self.thread_pool:
self.log_signal.emit(" Final thread pool check: Shutting down...")
self.cancellation_event.set() # Ensure cancellation event is set
self.thread_pool.shutdown(wait=True, cancel_futures=True) # Wait for shutdown
self.thread_pool = None
self.log_signal.emit("👋 Exiting application.")
event.accept() # Accept the close event
def init_ui(self):
"""Initializes all UI elements and layouts."""
# Main layout splitter (divides window into left controls panel and right logs panel)
self.main_splitter = QSplitter(Qt.Horizontal)
left_panel_widget = QWidget() # Container widget for the left panel
right_panel_widget = QWidget() # Container widget for the right panel
left_layout = QVBoxLayout(left_panel_widget) # Main vertical layout for the left panel
right_layout = QVBoxLayout(right_panel_widget) # Main vertical layout for the right panel
left_layout.setContentsMargins(10, 10, 10, 10) # Add some padding around left panel contents
right_layout.setContentsMargins(10, 10, 10, 10) # Add padding around right panel contents
# --- Left Panel (Controls) ---
# URL and Page Range Input Section
url_page_layout = QHBoxLayout() # Horizontal layout for URL and page range inputs
url_page_layout.setContentsMargins(0,0,0,0) # No internal margins for this specific QHBoxLayout
url_page_layout.addWidget(QLabel("🔗 Kemono Creator/Post URL:"))
self.link_input = QLineEdit()
self.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765")
self.link_input.textChanged.connect(self.update_custom_folder_visibility) # Connect to update custom folder UI
url_page_layout.addWidget(self.link_input, 1) # Allow URL input to stretch
# Page range inputs (Start and End)
self.page_range_label = QLabel("Page Range:")
self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;") # Style for emphasis
self.start_page_input = QLineEdit()
self.start_page_input.setPlaceholderText("Start")
self.start_page_input.setFixedWidth(50) # Fixed width for small input
self.start_page_input.setValidator(QIntValidator(1, 99999)) # Allow only positive integers
self.to_label = QLabel("to") # Simple "to" label between inputs
self.end_page_input = QLineEdit()
self.end_page_input.setPlaceholderText("End")
self.end_page_input.setFixedWidth(50)
self.end_page_input.setValidator(QIntValidator(1, 99999))
# Add page range widgets to the horizontal layout
url_page_layout.addWidget(self.page_range_label)
url_page_layout.addWidget(self.start_page_input)
url_page_layout.addWidget(self.to_label)
url_page_layout.addWidget(self.end_page_input)
left_layout.addLayout(url_page_layout) # Add URL/Page layout to the main left layout
# Download Directory Input Section
left_layout.addWidget(QLabel("📁 Download Location:"))
self.dir_input = QLineEdit()
self.dir_input.setPlaceholderText("Select folder where downloads will be saved")
self.dir_button = QPushButton("Browse...") # Button to open file dialog
self.dir_button.clicked.connect(self.browse_directory)
dir_layout = QHBoxLayout() # Horizontal layout for directory input and browse button
dir_layout.addWidget(self.dir_input, 1) # Allow directory input to stretch
dir_layout.addWidget(self.dir_button)
left_layout.addLayout(dir_layout)
# Container for Character Filter and Custom Folder (to manage visibility together)
self.filters_and_custom_folder_container_widget = QWidget()
filters_and_custom_folder_layout = QHBoxLayout(self.filters_and_custom_folder_container_widget)
filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0) # Top margin, no others
filters_and_custom_folder_layout.setSpacing(10) # Spacing between filter and custom folder
# Character Filter (will be added to the container)
self.character_filter_widget = QWidget() # Dedicated widget for character filter
character_filter_v_layout = QVBoxLayout(self.character_filter_widget)
character_filter_v_layout.setContentsMargins(0,0,0,0) # No internal margins for this VBox
character_filter_v_layout.setSpacing(2) # Minimal spacing between label and input
self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):")
self.character_input = QLineEdit()
self.character_input.setPlaceholderText("e.g., yor, Tifa, Reyna")
character_filter_v_layout.addWidget(self.character_label)
character_filter_v_layout.addWidget(self.character_input)
# Custom Folder Name (will be added to the container)
self.custom_folder_widget = QWidget() # Dedicated widget for custom folder input
custom_folder_v_layout = QVBoxLayout(self.custom_folder_widget)
custom_folder_v_layout.setContentsMargins(0,0,0,0) # No internal margins
custom_folder_v_layout.setSpacing(2)
self.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):")
self.custom_folder_input = QLineEdit()
self.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder")
custom_folder_v_layout.addWidget(self.custom_folder_label)
custom_folder_v_layout.addWidget(self.custom_folder_input)
self.custom_folder_widget.setVisible(False) # Initially hidden, shown based on URL and settings
# Add character filter and custom folder widgets to their container layout
filters_and_custom_folder_layout.addWidget(self.character_filter_widget, 1) # Allow stretch
filters_and_custom_folder_layout.addWidget(self.custom_folder_widget, 1) # Allow stretch
# Add the container widget to the main left layout
left_layout.addWidget(self.filters_and_custom_folder_container_widget)
# Skip Words Input Section
left_layout.addWidget(QLabel("🚫 Skip with Words (comma-separated):"))
skip_input_and_button_layout = QHBoxLayout() # Horizontal layout for skip words input and scope button
skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
skip_input_and_button_layout.setSpacing(10)
self.skip_words_input = QLineEdit()
self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
skip_input_and_button_layout.addWidget(self.skip_words_input, 3) # Give more space to input
self.skip_scope_toggle_button = QPushButton() # Text set by _update_skip_scope_button_text
self._update_skip_scope_button_text() # Set initial text based on loaded/default scope
self.skip_scope_toggle_button.setToolTip("Click to cycle skip scope (Files -> Posts -> Both)")
self.skip_scope_toggle_button.setStyleSheet("padding: 6px 10px;") # Ensure consistent padding
self.skip_scope_toggle_button.setMinimumWidth(100) # Ensure button is wide enough for text
skip_input_and_button_layout.addWidget(self.skip_scope_toggle_button, 1) # Add scope button
left_layout.addLayout(skip_input_and_button_layout)
# File Filter Radio Buttons Section
file_filter_layout = QVBoxLayout() # Vertical layout for the file filter section
file_filter_layout.setContentsMargins(0,10,0,0) # Add some top margin for separation
file_filter_layout.addWidget(QLabel("Filter Files:")) # Section label
radio_button_layout = QHBoxLayout() # Horizontal layout for the radio buttons themselves
radio_button_layout.setSpacing(10) # Adjusted spacing between radio buttons
self.radio_group = QButtonGroup(self) # Group to ensure only one radio button is selected
# Define radio buttons
self.radio_all = QRadioButton("All")
self.radio_images = QRadioButton("Images/GIFs")
self.radio_videos = QRadioButton("Videos")
self.radio_only_archives = QRadioButton("📦 Only Archives") # New radio button for archives
self.radio_only_links = QRadioButton("🔗 Only Links")
self.radio_all.setChecked(True) # Default selection
# Add buttons to the group
self.radio_group.addButton(self.radio_all)
self.radio_group.addButton(self.radio_images)
self.radio_group.addButton(self.radio_videos)
self.radio_group.addButton(self.radio_only_archives) # Add new button to group
self.radio_group.addButton(self.radio_only_links)
# Add buttons to the horizontal layout
radio_button_layout.addWidget(self.radio_all)
radio_button_layout.addWidget(self.radio_images)
radio_button_layout.addWidget(self.radio_videos)
radio_button_layout.addWidget(self.radio_only_archives) # Add new button to layout
radio_button_layout.addWidget(self.radio_only_links)
radio_button_layout.addStretch(1) # Push buttons to the left, filling remaining space
file_filter_layout.addLayout(radio_button_layout) # Add radio button layout to section layout
left_layout.addLayout(file_filter_layout) # Add section layout to main left layout
# Checkboxes Group Section (for various download options)
checkboxes_group_layout = QVBoxLayout() # Vertical layout for checkbox groups
checkboxes_group_layout.setSpacing(10) # Spacing between rows of checkboxes
# Row 1 of Checkboxes (Skip ZIP/RAR, Thumbnails, Compress)
row1_layout = QHBoxLayout() # Horizontal layout for the first row of checkboxes
row1_layout.setSpacing(10)
self.skip_zip_checkbox = QCheckBox("Skip .zip")
self.skip_zip_checkbox.setChecked(True) # Default to skipping ZIPs
row1_layout.addWidget(self.skip_zip_checkbox)
self.skip_rar_checkbox = QCheckBox("Skip .rar")
self.skip_rar_checkbox.setChecked(True) # Default to skipping RARs
row1_layout.addWidget(self.skip_rar_checkbox)
self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
self.download_thumbnails_checkbox.setChecked(False) # Default to not downloading only thumbnails
self.download_thumbnails_checkbox.setToolTip("Thumbnail download functionality is currently limited without the API.")
row1_layout.addWidget(self.download_thumbnails_checkbox)
self.compress_images_checkbox = QCheckBox("Compress Large Images (to WebP)")
self.compress_images_checkbox.setChecked(False) # Default to not compressing images
self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).")
row1_layout.addWidget(self.compress_images_checkbox)
row1_layout.addStretch(1) # Push checkboxes to the left
checkboxes_group_layout.addLayout(row1_layout) # Add row to the group layout
# Advanced Settings Label and Checkboxes
advanced_settings_label = QLabel("⚙️ Advanced Settings:") # Label for advanced settings section
checkboxes_group_layout.addWidget(advanced_settings_label)
# Advanced Row 1 (Subfolders)
advanced_row1_layout = QHBoxLayout() # Horizontal layout for first row of advanced checkboxes
advanced_row1_layout.setSpacing(10)
self.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title")
self.use_subfolders_checkbox.setChecked(True) # Default to using subfolders
self.use_subfolders_checkbox.toggled.connect(self.update_ui_for_subfolders) # Connect to update UI
advanced_row1_layout.addWidget(self.use_subfolders_checkbox)
self.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
self.use_subfolder_per_post_checkbox.setChecked(False) # Default to not using subfolder per post
self.use_subfolder_per_post_checkbox.setToolTip("Creates a subfolder for each post inside the character/title folder.")
self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders) # Connect to update UI
advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox)
advanced_row1_layout.addStretch(1) # Push to left
checkboxes_group_layout.addLayout(advanced_row1_layout)
# Advanced Row 2 (Multithreading, External Links, Manga Mode)
advanced_row2_layout = QHBoxLayout() # Horizontal layout for second row of advanced checkboxes
advanced_row2_layout.setSpacing(10)
# Multithreading specific layout (checkbox, label, input)
multithreading_layout = QHBoxLayout()
multithreading_layout.setContentsMargins(0,0,0,0) # No internal margins for this group
self.use_multithreading_checkbox = QCheckBox("Use Multithreading")
self.use_multithreading_checkbox.setChecked(True) # Default to using multithreading
self.use_multithreading_checkbox.setToolTip( # Updated tooltip explaining thread count usage
"Enables concurrent operations. See 'Threads' input for details."
)
multithreading_layout.addWidget(self.use_multithreading_checkbox)
self.thread_count_label = QLabel("Threads:") # Label for thread count input
multithreading_layout.addWidget(self.thread_count_label)
self.thread_count_input = QLineEdit() # Input for number of threads
self.thread_count_input.setFixedWidth(40) # Small fixed width
self.thread_count_input.setText("4") # Default thread count
self.thread_count_input.setToolTip( # Updated tooltip explaining thread usage contexts
f"Number of concurrent operations.\n"
f"- Single Post: Concurrent file downloads (1-{MAX_FILE_THREADS_PER_POST_OR_WORKER} recommended).\n"
f"- Creator Feed: Concurrent post processing (1-{MAX_THREADS}).\n"
f" File downloads per post worker also use this value (1-{MAX_FILE_THREADS_PER_POST_OR_WORKER} recommended)."
)
self.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS)) # Validate input (1 to MAX_THREADS)
multithreading_layout.addWidget(self.thread_count_input)
advanced_row2_layout.addLayout(multithreading_layout) # Add multithreading group to advanced row 2
# External Links Checkbox
self.external_links_checkbox = QCheckBox("Show External Links in Log")
self.external_links_checkbox.setChecked(False) # Default to not showing external links log separately
advanced_row2_layout.addWidget(self.external_links_checkbox)
# Manga Mode Checkbox
self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
self.manga_mode_checkbox.setToolTip("Downloads posts from oldest to newest and renames files based on post title (for creator feeds only).")
self.manga_mode_checkbox.setChecked(False) # Default to manga mode off
advanced_row2_layout.addWidget(self.manga_mode_checkbox)
advanced_row2_layout.addStretch(1) # Push to left
checkboxes_group_layout.addLayout(advanced_row2_layout) # Add advanced row 2 to group layout
left_layout.addLayout(checkboxes_group_layout) # Add checkbox group layout to main left layout
# Download and Cancel Buttons Section
btn_layout = QHBoxLayout() # Horizontal layout for main action buttons
btn_layout.setSpacing(10)
self.download_btn = QPushButton("⬇️ Start Download")
self.download_btn.setStyleSheet("padding: 8px 15px; font-weight: bold;") # Make download button prominent
self.download_btn.clicked.connect(self.start_download) # Connect to start download logic
self.cancel_btn = QPushButton("❌ Cancel")
self.cancel_btn.setEnabled(False) # Initially disabled, enabled when download is active
self.cancel_btn.clicked.connect(self.cancel_download) # Connect to cancel download logic
btn_layout.addWidget(self.download_btn)
btn_layout.addWidget(self.cancel_btn)
left_layout.addLayout(btn_layout) # Add button layout to main left layout
left_layout.addSpacing(10) # Add some space after buttons
# Known Characters/Shows List Section
known_chars_label_layout = QHBoxLayout() # Layout for label and search input for known characters
known_chars_label_layout.setSpacing(10)
self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):")
self.character_search_input = QLineEdit() # Input to filter the character list
self.character_search_input.setPlaceholderText("Search characters...")
known_chars_label_layout.addWidget(self.known_chars_label, 1) # Allow label to take space
known_chars_label_layout.addWidget(self.character_search_input)
left_layout.addLayout(known_chars_label_layout)
self.character_list = QListWidget() # List to display known characters
self.character_list.setSelectionMode(QListWidget.ExtendedSelection) # Allow multiple selections for deletion
left_layout.addWidget(self.character_list, 1) # Allow list to stretch vertically
# Character Management Buttons Section (Add/Delete)
char_manage_layout = QHBoxLayout() # Layout for adding/deleting characters from the list
char_manage_layout.setSpacing(10)
self.new_char_input = QLineEdit() # Input for new character name
self.new_char_input.setPlaceholderText("Add new show/character name")
self.add_char_button = QPushButton(" Add") # Button to add new character
self.delete_char_button = QPushButton("🗑️ Delete Selected") # Button to delete selected characters
self.add_char_button.clicked.connect(self.add_new_character) # Connect add button
self.new_char_input.returnPressed.connect(self.add_char_button.click) # Allow adding on Enter key press
self.delete_char_button.clicked.connect(self.delete_selected_character) # Connect delete button
char_manage_layout.addWidget(self.new_char_input, 2) # Give more space to input field
char_manage_layout.addWidget(self.add_char_button, 1)
char_manage_layout.addWidget(self.delete_char_button, 1)
left_layout.addLayout(char_manage_layout) # Add management buttons layout to main left layout
left_layout.addStretch(0) # Prevent excessive stretching at the bottom of left panel
# --- Right Panel (Logs) ---
log_title_layout = QHBoxLayout() # Layout for log title and utility buttons (verbosity, reset)
self.progress_log_label = QLabel("📜 Progress Log:") # Main label for the log area
log_title_layout.addWidget(self.progress_log_label)
log_title_layout.addStretch(1) # Push utility buttons to the right
# Link Search Input and Button (initially hidden, for "Only Links" mode)
self.link_search_input = QLineEdit()
self.link_search_input.setPlaceholderText("Search Links...")
self.link_search_input.setVisible(False) # Hidden by default
self.link_search_input.setFixedWidth(150)
log_title_layout.addWidget(self.link_search_input)
self.link_search_button = QPushButton("🔍") # Search icon button
self.link_search_button.setToolTip("Filter displayed links")
self.link_search_button.setVisible(False) # Hidden by default
self.link_search_button.setFixedWidth(30)
self.link_search_button.setStyleSheet("padding: 4px 4px;") # Compact padding
log_title_layout.addWidget(self.link_search_button)
# Manga Rename Toggle Button (initially hidden, for Manga Mode)
self.manga_rename_toggle_button = QPushButton() # Text set by _update_manga_filename_style_button_text
self.manga_rename_toggle_button.setVisible(False) # Hidden by default
self.manga_rename_toggle_button.setFixedWidth(140) # Adjusted width for text
self.manga_rename_toggle_button.setStyleSheet("padding: 4px 8px;")
self._update_manga_filename_style_button_text() # Set initial text based on loaded style
log_title_layout.addWidget(self.manga_rename_toggle_button)
# Log Verbosity Toggle Button
self.log_verbosity_button = QPushButton("Show Basic Log") # Button to toggle log detail
self.log_verbosity_button.setToolTip("Toggle between full and basic log details.")
self.log_verbosity_button.setFixedWidth(110) # Fixed width
self.log_verbosity_button.setStyleSheet("padding: 4px 8px;")
log_title_layout.addWidget(self.log_verbosity_button)
# Reset Button
self.reset_button = QPushButton("🔄 Reset") # Button to reset application state
self.reset_button.setToolTip("Reset all inputs and logs to default state (only when idle).")
self.reset_button.setFixedWidth(80)
self.reset_button.setStyleSheet("padding: 4px 8px;")
log_title_layout.addWidget(self.reset_button)
right_layout.addLayout(log_title_layout) # Add log title/utility layout to main right layout
# Log Output Areas (Splitter for Main and External Logs)
self.log_splitter = QSplitter(Qt.Vertical) # Vertical splitter for two log areas
self.main_log_output = QTextEdit() # Main log display
self.main_log_output.setReadOnly(True) # Make it read-only
self.main_log_output.setLineWrapMode(QTextEdit.NoWrap) # No wrap for better log readability
self.main_log_output.setStyleSheet("""
QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px;
color: #F0F0F0; border-radius: 4px; font-family: Consolas, Courier New, monospace; font-size: 9.5pt; }""")
self.external_log_output = QTextEdit() # External links log display
self.external_log_output.setReadOnly(True)
self.external_log_output.setLineWrapMode(QTextEdit.NoWrap)
self.external_log_output.setStyleSheet("""
QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px;
color: #F0F0F0; border-radius: 4px; font-family: Consolas, Courier New, monospace; font-size: 9.5pt; }""")
self.external_log_output.hide() # Initially hidden, shown when "Show External Links" is checked
self.log_splitter.addWidget(self.main_log_output) # Add main log to splitter
self.log_splitter.addWidget(self.external_log_output) # Add external log to splitter
self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space initially
right_layout.addWidget(self.log_splitter, 1) # Allow splitter to stretch vertically
# Export Links Button (initially hidden, for "Only Links" mode)
export_button_layout = QHBoxLayout() # Layout to push button to the right
export_button_layout.addStretch(1) # Push to right
self.export_links_button = QPushButton("Export Links")
self.export_links_button.setToolTip("Export all extracted links to a .txt file.")
self.export_links_button.setFixedWidth(100)
self.export_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;")
self.export_links_button.setEnabled(False) # Initially disabled
self.export_links_button.setVisible(False) # Initially hidden
export_button_layout.addWidget(self.export_links_button)
right_layout.addLayout(export_button_layout)
# Progress Labels (Overall and Individual File)
self.progress_label = QLabel("Progress: Idle") # Label for overall download progress
self.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;")
right_layout.addWidget(self.progress_label)
self.file_progress_label = QLabel("") # Label for individual file download progress
self.file_progress_label.setWordWrap(True) # Allow text to wrap if long
self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(self.file_progress_label)
# Add left and right panels to the main splitter
self.main_splitter.addWidget(left_panel_widget)
self.main_splitter.addWidget(right_panel_widget)
# Set initial splitter sizes (e.g., 35% for left controls, 65% for right logs)
initial_width = self.width()
left_width = int(initial_width * 0.35)
right_width = initial_width - left_width
self.main_splitter.setSizes([left_width, right_width])
# Set main layout for the window
top_level_layout = QHBoxLayout(self) # Top-level layout for the main window
top_level_layout.setContentsMargins(0,0,0,0) # No margins for the top-level layout itself
top_level_layout.addWidget(self.main_splitter) # Add the main splitter to the window's layout
# Initial UI state updates based on defaults and loaded settings
self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked())
self.update_external_links_setting(self.external_links_checkbox.isChecked())
self.update_multithreading_label(self.thread_count_input.text())
self.update_page_range_enabled_state() # Call after link_input is created
if self.manga_mode_checkbox: # Ensure checkbox exists before accessing
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked()) # Call after manga_mode_checkbox created
if hasattr(self, 'link_input'): self.link_input.textChanged.connect(self.update_page_range_enabled_state) # Connect page range update
self.load_known_names_from_util() # Load known names into the list widget
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked()) # Set initial state of thread count input
if hasattr(self, 'radio_group') and self.radio_group.checkedButton(): # Ensure radio group and a checked button exist
self._handle_filter_mode_change(self.radio_group.checkedButton(), True) # Set initial UI based on default radio selection
self._update_manga_filename_style_button_text() # Set initial text for manga rename button
self._update_skip_scope_button_text() # Set initial text for skip scope button
def get_dark_theme(self):
"""Returns a string containing CSS for a dark theme."""
return """
QWidget { background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; }
QLineEdit, QListWidget { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; }
QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; min-height: 22px; }
QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; }
QPushButton:pressed { background-color: #4A4A4A; }
QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; }
QLabel { font-weight: bold; padding-top: 4px; padding-bottom: 2px; color: #C0C0C0; }
QRadioButton, QCheckBox { spacing: 5px; color: #E0E0E0; padding-top: 4px; padding-bottom: 4px; }
QRadioButton::indicator, QCheckBox::indicator { width: 14px; height: 14px; }
QListWidget { alternate-background-color: #353535; border: 1px solid #5A5A5A; }
QListWidget::item:selected { background-color: #007ACC; color: #FFFFFF; }
QToolTip { background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 4px; border-radius: 3px; }
QSplitter::handle { background-color: #5A5A5A; /* Thicker handle for easier grabbing */ }
QSplitter::handle:horizontal { width: 5px; }
QSplitter::handle:vertical { height: 5px; }
/* Style for QFrame used as a separator or container if needed */
QFrame[frameShape="4"], QFrame[frameShape="5"] { /* HLine, VLine */
border: 1px solid #4A4A4A; /* Darker line for subtle separation */
border-radius: 3px;
}
"""
def browse_directory(self):
"""Opens a dialog to select the download directory."""
# Get current directory from input if valid, otherwise use home directory or last used
current_dir = self.dir_input.text() if os.path.isdir(self.dir_input.text()) else ""
folder = QFileDialog.getExistingDirectory(self, "Select Download Folder", current_dir)
if folder: # If a folder was selected
self.dir_input.setText(folder) # Update the directory input field
def handle_main_log(self, message):
"""Appends a message to the main log output area, handling HTML and basic log mode."""
is_html_message = message.startswith(HTML_PREFIX) # Check if message is flagged as HTML
display_message = message
use_html = False
if is_html_message:
display_message = message[len(HTML_PREFIX):] # Remove HTML prefix
use_html = True
elif self.basic_log_mode: # If basic log mode is active, filter messages
# Keywords that indicate a message should be shown in basic mode
basic_keywords = [
'🚀 starting download', '🏁 download finished', '🏁 download cancelled', # Start/End messages
'', '⚠️', '✅ all posts processed', '✅ reached end of posts', # Errors, Warnings, Key Milestones
'summary:', 'progress:', '[fetcher]', # Summaries, Progress, Fetcher logs
'critical error', 'import error', 'error', 'fail', 'timeout', # Specific error types
'unsupported url', 'invalid url', 'no posts found', 'could not create directory', # Common operational issues
'missing dependency', 'high thread count', 'manga mode filter warning', # Configuration/Setup warnings
'duplicate name', 'potential name conflict', 'invalid filter name', # Known list issues
'no valid character filters' # Filter issues
]
message_lower = message.lower() # For case-insensitive keyword check
if not any(keyword in message_lower for keyword in basic_keywords):
# Allow specific success messages even in basic mode if they are not too verbose
if not message.strip().startswith("✅ Saved:") and \
not message.strip().startswith("✅ Added") and \
not message.strip().startswith("✅ Application reset complete"):
return # Skip message if not matching keywords and not an allowed specific success message
try:
# Sanitize null characters that can crash QTextEdit
safe_message = str(display_message).replace('\x00', '[NULL]')
if use_html:
self.main_log_output.insertHtml(safe_message) # Insert as HTML
else:
self.main_log_output.append(safe_message) # Append as plain text
# Auto-scroll if the scrollbar is near the bottom
scrollbar = self.main_log_output.verticalScrollBar()
if scrollbar.value() >= scrollbar.maximum() - 30: # Threshold for auto-scroll
scrollbar.setValue(scrollbar.maximum()) # Scroll to the bottom
except Exception as e:
# Fallback print if GUI logging fails for some reason
print(f"GUI Main Log Error: {e}\nOriginal Message: {message}")
def _is_download_active(self):
"""Checks if any download process (single or multi-threaded for posts) is currently active."""
single_thread_active = self.download_thread and self.download_thread.isRunning()
# Check if thread_pool exists and has any non-done futures
pool_active = self.thread_pool is not None and any(not f.done() for f in self.active_futures if f is not None)
return single_thread_active or pool_active
def handle_external_link_signal(self, post_title, link_text, link_url, platform):
"""Handles external links found by worker threads by adding them to a queue for processing."""
link_data = (post_title, link_text, link_url, platform)
self.external_link_queue.append(link_data) # Add to queue
if self.radio_only_links and self.radio_only_links.isChecked():
self.extracted_links_cache.append(link_data) # Also add to cache for "Only Links" mode display
self._try_process_next_external_link() # Attempt to process immediately or schedule
def _try_process_next_external_link(self):
"""Processes the next external link from the queue with appropriate delays to avoid flooding the UI."""
if self._is_processing_external_link_queue or not self.external_link_queue:
# Already processing or queue is empty, so return
return
# Determine if links should be displayed in the external log or main log (for "Only Links" mode)
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
should_display_in_external_log = self.show_external_links and not is_only_links_mode
if not (is_only_links_mode or should_display_in_external_log):
# Neither "Only Links" mode nor "Show External Links" is active for displaying this link now.
# It's queued, but we don't need to display it immediately.
self._is_processing_external_link_queue = False # Ensure flag is reset
if self.external_link_queue: # If there are still items, try again later (e.g., if settings change)
QTimer.singleShot(0, self._try_process_next_external_link) # Check again soon
return
self._is_processing_external_link_queue = True # Set flag that we are processing one
link_data = self.external_link_queue.popleft() # Get the next link from the queue
# Apply different delays based on context to manage UI updates
if is_only_links_mode:
# Shorter delay for "Only Links" mode as it's the primary output
delay_ms = 80 # milliseconds
QTimer.singleShot(delay_ms, lambda data=link_data: self._display_and_schedule_next(data))
elif self._is_download_active(): # If a download is active, use a longer, randomized delay
delay_ms = random.randint(4000, 8000) # 4-8 seconds
QTimer.singleShot(delay_ms, lambda data=link_data: self._display_and_schedule_next(data))
else: # No download active, process with minimal delay
QTimer.singleShot(0, lambda data=link_data: self._display_and_schedule_next(data))
def _display_and_schedule_next(self, link_data):
"""Displays a single external link and schedules the processing of the next one from the queue."""
post_title, link_text, link_url, platform = link_data
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
# Format link for display (truncate long link text)
max_link_text_len = 35
display_text = link_text[:max_link_text_len].strip() + "..." if len(link_text) > max_link_text_len else link_text
formatted_link_info = f"{display_text} - {link_url} - {platform}"
separator = "-" * 45 # Separator for visual grouping by post in "Only Links" mode
if is_only_links_mode:
# In "Only Links" mode, display in the main log
if post_title != self._current_link_post_title: # If it's a new post title
self.log_signal.emit(HTML_PREFIX + "<br>" + separator + "<br>") # Add separator and space using HTML
title_html = f'<b style="color: #87CEEB;">{post_title}</b><br>' # Make post title prominent
self.log_signal.emit(HTML_PREFIX + title_html) # Emit title as HTML
self._current_link_post_title = post_title # Update current title tracker
self.log_signal.emit(formatted_link_info) # Emit the link info as plain text
elif self.show_external_links: # If "Show External Links" is checked (and not "Only Links" mode)
# Display in the dedicated external links log
self._append_to_external_log(formatted_link_info, separator) # Pass separator for consistency if needed
# Reset flag and try to process the next link in the queue
self._is_processing_external_link_queue = False
self._try_process_next_external_link()
def _append_to_external_log(self, formatted_link_text, separator):
"""Appends a formatted link to the external log output if it's visible."""
if not (self.external_log_output and self.external_log_output.isVisible()):
return # Don't append if log area is hidden
try:
# Append the formatted link text
self.external_log_output.append(formatted_link_text)
self.external_log_output.append("") # Add a blank line for spacing between links
# Auto-scroll if near the bottom
scrollbar = self.external_log_output.verticalScrollBar()
if scrollbar.value() >= scrollbar.maximum() - 50: # Threshold for auto-scroll
scrollbar.setValue(scrollbar.maximum()) # Scroll to bottom
except Exception as e:
# Fallback if GUI logging fails
self.log_signal.emit(f"GUI External Log Append Error: {e}\nOriginal Message: {formatted_link_text}") # Log to main log as fallback
print(f"GUI External Log Error (Append): {e}\nOriginal Message: {formatted_link_text}")
def update_file_progress_display(self, filename, downloaded_bytes, total_bytes):
"""Updates the label showing individual file download progress."""
if not filename and total_bytes == 0 and downloaded_bytes == 0: # Clear signal
self.file_progress_label.setText("") # Clear the progress label
return
max_filename_len = 25 # Max length for filename part of the string for display
display_filename = filename
if len(filename) > max_filename_len: # Truncate if too long
display_filename = filename[:max_filename_len-3].strip() + "..."
# Format progress text
if total_bytes > 0: # If total size is known
downloaded_mb = downloaded_bytes / (1024 * 1024)
total_mb = total_bytes / (1024 * 1024)
progress_text = f"Downloading '{display_filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)"
else: # If total size is unknown
downloaded_mb = downloaded_bytes / (1024 * 1024)
progress_text = f"Downloading '{display_filename}' ({downloaded_mb:.1f}MB)"
# Further shorten if the whole string is too long for the UI label
if len(progress_text) > 75: # Heuristic length limit for the label
# Shorter truncate for filename if the whole string is still too long
display_filename = filename[:15].strip() + "..." if len(filename) > 18 else display_filename
if total_bytes > 0: progress_text = f"DL '{display_filename}' ({downloaded_mb:.1f}/{total_mb:.1f}MB)"
else: progress_text = f"DL '{display_filename}' ({downloaded_mb:.1f}MB)"
self.file_progress_label.setText(progress_text) # Update the label text
def update_external_links_setting(self, checked):
"""Handles changes to the 'Show External Links in Log' checkbox, updating UI visibility."""
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives_mode = self.radio_only_archives and self.radio_only_archives.isChecked() # Check new mode
# External links log is not shown for "Only Links" or "Only Archives" mode, regardless of checkbox state
if is_only_links_mode or is_only_archives_mode:
if self.external_log_output: self.external_log_output.hide() # Hide external log
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space
# self.show_external_links should ideally be false if these modes are active,
# and the checkbox should be disabled by _handle_filter_mode_change.
return # Exit early, no further action needed for these modes
self.show_external_links = checked # Update the internal flag based on checkbox state
if checked:
# Show the external log area
if self.external_log_output: self.external_log_output.show()
if self.log_splitter: self.log_splitter.setSizes([self.height() // 2, self.height() // 2]) # Split space between logs
if self.main_log_output: self.main_log_output.setMinimumHeight(50) # Ensure some min height for main log
if self.external_log_output: self.external_log_output.setMinimumHeight(50) # Ensure min height for external log
self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Enabled\n" + "="*40) # Log change
if self.external_log_output: # Clear and add title if showing external log
self.external_log_output.clear()
self.external_log_output.append("🔗 External Links Found:")
self._try_process_next_external_link() # Process any queued links now that log is visible
else:
# Hide the external log area
if self.external_log_output: self.external_log_output.hide()
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space
if self.main_log_output: self.main_log_output.setMinimumHeight(0) # Reset min height
if self.external_log_output: self.external_log_output.setMinimumHeight(0) # Reset min height
if self.external_log_output: self.external_log_output.clear() # Clear content when hiding
self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Disabled\n" + "="*40) # Log change
def _handle_filter_mode_change(self, button, checked):
"""Handles changes in the file filter radio buttons, updating UI accordingly."""
if not button or not checked: # Only act on the button that was toggled to 'checked'
return
filter_mode_text = button.text() # Get text of the selected radio button
is_only_links = (filter_mode_text == "🔗 Only Links")
is_only_archives = (filter_mode_text == "📦 Only Archives") # Check for "Only Archives" mode
# --- Visibility of Link-Specific UI (Search, Export) ---
if self.link_search_input: self.link_search_input.setVisible(is_only_links)
if self.link_search_button: self.link_search_button.setVisible(is_only_links)
if self.export_links_button:
self.export_links_button.setVisible(is_only_links)
# Enable export button only if in links mode and there are cached links
self.export_links_button.setEnabled(is_only_links and bool(self.extracted_links_cache))
if not is_only_links and self.link_search_input: self.link_search_input.clear() # Clear search if not in links mode
# --- Enable/Disable State of General Download-Related Widgets ---
# File download mode is active if NOT "Only Links" mode
file_download_mode_active = not is_only_links
# Widgets generally active for file downloads (All, Images, Videos, Archives)
if self.dir_input: self.dir_input.setEnabled(file_download_mode_active)
if self.dir_button: self.dir_button.setEnabled(file_download_mode_active)
if self.use_subfolders_checkbox: self.use_subfolders_checkbox.setEnabled(file_download_mode_active)
# Skip words input and scope button are relevant if downloading files
if self.skip_words_input: self.skip_words_input.setEnabled(file_download_mode_active)
if self.skip_scope_toggle_button: self.skip_scope_toggle_button.setEnabled(file_download_mode_active)
# --- Skip Archive Checkboxes Logic ---
# Enabled if NOT "Only Links" AND NOT "Only Archives"
# Unchecked and disabled if "Only Archives" mode is selected
if self.skip_zip_checkbox:
can_skip_zip = not is_only_links and not is_only_archives
self.skip_zip_checkbox.setEnabled(can_skip_zip)
if is_only_archives:
self.skip_zip_checkbox.setChecked(False) # Ensure unchecked in "Only Archives" mode
if self.skip_rar_checkbox:
can_skip_rar = not is_only_links and not is_only_archives
self.skip_rar_checkbox.setEnabled(can_skip_rar)
if is_only_archives:
self.skip_rar_checkbox.setChecked(False) # Ensure unchecked in "Only Archives" mode
# --- Other File Processing Checkboxes (Thumbnails, Compression) ---
# Enabled if NOT "Only Links" AND NOT "Only Archives"
other_file_proc_enabled = not is_only_links and not is_only_archives
if self.download_thumbnails_checkbox: self.download_thumbnails_checkbox.setEnabled(other_file_proc_enabled)
if self.compress_images_checkbox: self.compress_images_checkbox.setEnabled(other_file_proc_enabled)
# --- External Links Checkbox Logic ---
# Enabled if NOT "Only Links" AND NOT "Only Archives"
if self.external_links_checkbox:
can_show_external_log_option = not is_only_links and not is_only_archives
self.external_links_checkbox.setEnabled(can_show_external_log_option)
if not can_show_external_log_option: # If disabled due to current mode
self.external_links_checkbox.setChecked(False) # Uncheck it
# --- Log Area and Specific Mode UI Updates ---
if is_only_links: # "Only Links" mode specific UI
self.progress_log_label.setText("📜 Extracted Links Log:") # Change log label
if self.external_log_output: self.external_log_output.hide() # Hide separate external log area
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space
if self.main_log_output: self.main_log_output.clear(); self.main_log_output.setMinimumHeight(0) # Clear main log
if self.external_log_output: self.external_log_output.clear(); self.external_log_output.setMinimumHeight(0) # Clear external log
self.log_signal.emit("="*20 + " Mode changed to: Only Links " + "="*20) # Log mode change
self._filter_links_log() # Refresh link log display based on current cache and search
self._try_process_next_external_link() # Process any queued links for this mode
elif is_only_archives: # "Only Archives" mode specific UI
self.progress_log_label.setText("📜 Progress Log (Archives Only):") # Change log label
if self.external_log_output: self.external_log_output.hide() # Hide external links log for archives mode
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space
if self.main_log_output: self.main_log_output.clear() # Clear main log for new mode
self.log_signal.emit("="*20 + " Mode changed to: Only Archives " + "="*20) # Log mode change
else: # All, Images, Videos modes
self.progress_log_label.setText("📜 Progress Log:") # Default log label
# For these modes, the external links log visibility depends on its checkbox state
self.update_external_links_setting(self.external_links_checkbox.isChecked() if self.external_links_checkbox else False)
self.log_signal.emit(f"="*20 + f" Mode changed to: {filter_mode_text} " + "="*20) # Log mode change
# --- Common UI Updates based on current states (called after mode-specific changes) ---
# Update subfolder related UI (character filter, per-post subfolder checkbox, custom folder input)
if self.use_subfolders_checkbox: # Ensure it exists
self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked())
# Update visibility of custom folder input (depends on single post URL and subfolder settings)
self.update_custom_folder_visibility()
def _filter_links_log(self):
"""Filters and displays links in the main log when 'Only Links' mode is active, based on search input."""
if not (self.radio_only_links and self.radio_only_links.isChecked()): return # Only run in "Only Links" mode
search_term = self.link_search_input.text().lower().strip() if self.link_search_input else ""
self.main_log_output.clear() # Clear previous content from the main log
current_title_for_display = None # To group links by post title in the display
separator = "-" * 45 # Visual separator between post sections
# Iterate through the cached extracted links
for post_title, link_text, link_url, platform in self.extracted_links_cache:
# Check if any part of the link data matches the search term (case-insensitive)
matches_search = (
not search_term or # Show all if no search term is provided
search_term in link_text.lower() or
search_term in link_url.lower() or
search_term in platform.lower()
)
if matches_search: # If the link matches the search criteria
if post_title != current_title_for_display: # If it's a new post section
self.main_log_output.insertHtml("<br>" + separator + "<br>") # Add separator and space using HTML
title_html = f'<b style="color: #87CEEB;">{post_title}</b><br>' # Format post title
self.main_log_output.insertHtml(title_html) # Insert title as HTML
current_title_for_display = post_title # Update current title tracker
# Format and display the link information
max_link_text_len = 35 # Truncate long link text for display
display_text = link_text[:max_link_text_len].strip() + "..." if len(link_text) > max_link_text_len else link_text
formatted_link_info = f"{display_text} - {link_url} - {platform}"
self.main_log_output.append(formatted_link_info) # Append link info as plain text
if self.main_log_output.toPlainText().strip(): # Add a final newline if content was added
self.main_log_output.append("")
self.main_log_output.verticalScrollBar().setValue(0) # Scroll to top of the log
def _export_links_to_file(self):
"""Exports extracted links to a text file when in 'Only Links' mode."""
if not (self.radio_only_links and self.radio_only_links.isChecked()):
QMessageBox.information(self, "Export Links", "Link export is only available in 'Only Links' mode.")
return
if not self.extracted_links_cache:
QMessageBox.information(self, "Export Links", "No links have been extracted yet.")
return
# Suggest a default filename for the export
default_filename = "extracted_links.txt"
filepath, _ = QFileDialog.getSaveFileName(self, "Save Links", default_filename, "Text Files (*.txt);;All Files (*)")
if filepath: # If a filepath was chosen
try:
with open(filepath, 'w', encoding='utf-8') as f:
current_title_for_export = None # To group links by post title in the file
separator = "-" * 60 + "\n" # Separator for file content
for post_title, link_text, link_url, platform in self.extracted_links_cache:
if post_title != current_title_for_export: # If it's a new post section
if current_title_for_export is not None: # Add separator before new post section (if not the first)
f.write("\n" + separator + "\n")
f.write(f"Post Title: {post_title}\n\n") # Write post title
current_title_for_export = post_title # Update current title tracker
# Write link details
f.write(f" {link_text} - {link_url} - {platform}\n")
self.log_signal.emit(f"✅ Links successfully exported to: {filepath}")
QMessageBox.information(self, "Export Successful", f"Links exported to:\n{filepath}")
except Exception as e:
self.log_signal.emit(f"❌ Error exporting links: {e}")
QMessageBox.critical(self, "Export Error", f"Could not export links: {e}")
def get_filter_mode(self):
"""Determines the backend filter mode ('all', 'image', 'video', 'archive') based on radio button selection."""
if self.radio_only_links and self.radio_only_links.isChecked():
# Backend expects 'all' for link extraction, even if UI says "Only Links",
# as the worker will then be told to extract_links_only.
return 'all'
elif self.radio_images.isChecked():
return 'image'
elif self.radio_videos.isChecked():
return 'video'
elif self.radio_only_archives and self.radio_only_archives.isChecked(): # Check for "Only Archives" mode
return 'archive'
elif self.radio_all.isChecked(): # Explicitly check for 'All' if others aren't matched
return 'all'
return 'all' # Default if somehow no button is checked (should not happen with QButtonGroup)
def get_skip_words_scope(self):
"""Returns the current scope for skip words (files, posts, or both) from the internal attribute."""
return self.skip_words_scope
def _update_skip_scope_button_text(self):
"""Updates the text of the skip scope toggle button based on the current self.skip_words_scope."""
if self.skip_scope_toggle_button: # Ensure button exists
if self.skip_words_scope == SKIP_SCOPE_FILES:
self.skip_scope_toggle_button.setText("Scope: Files")
elif self.skip_words_scope == SKIP_SCOPE_POSTS:
self.skip_scope_toggle_button.setText("Scope: Posts")
elif self.skip_words_scope == SKIP_SCOPE_BOTH:
self.skip_scope_toggle_button.setText("Scope: Both")
else: # Should not happen if logic is correct
self.skip_scope_toggle_button.setText("Scope: Unknown")
def _cycle_skip_scope(self):
"""Cycles through the available skip word scopes (Files -> Posts -> Both -> Files) and updates UI and settings."""
if self.skip_words_scope == SKIP_SCOPE_FILES:
self.skip_words_scope = SKIP_SCOPE_POSTS
elif self.skip_words_scope == SKIP_SCOPE_POSTS:
self.skip_words_scope = SKIP_SCOPE_BOTH
elif self.skip_words_scope == SKIP_SCOPE_BOTH:
self.skip_words_scope = SKIP_SCOPE_FILES
else: # Default to files if current state is unknown (should not occur)
self.skip_words_scope = SKIP_SCOPE_FILES
self._update_skip_scope_button_text() # Update button text to reflect new scope
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope) # Save the new scope to settings
self.log_signal.emit(f" Skip words scope changed to: '{self.skip_words_scope}'") # Log the change
def add_new_character(self):
"""Adds a new character/show name to the known list, with validation and conflict checks."""
global KNOWN_NAMES, clean_folder_name # Ensure we use the potentially shared KNOWN_NAMES and utility function
name_to_add = self.new_char_input.text().strip() # Get name from input and strip whitespace
if not name_to_add: # Check for empty input
QMessageBox.warning(self, "Input Error", "Name cannot be empty."); return False # Indicate failure
name_lower = name_to_add.lower() # For case-insensitive comparisons
# Check for exact duplicates (case-insensitive)
if any(existing.lower() == name_lower for existing in KNOWN_NAMES):
QMessageBox.warning(self, "Duplicate Name", f"The name '{name_to_add}' (case-insensitive) already exists."); return False
# Check for potential conflicts (substrings or superstrings)
similar_names_details = []
for existing_name in KNOWN_NAMES:
existing_name_lower = existing_name.lower()
# Check if new name is in existing OR existing is in new name (but not identical)
if name_lower != existing_name_lower and (name_lower in existing_name_lower or existing_name_lower in name_lower):
similar_names_details.append((name_to_add, existing_name)) # Store pair for message
if similar_names_details: # If potential conflicts found
first_similar_new, first_similar_existing = similar_names_details[0]
# Determine which name is shorter for the example message to illustrate potential grouping issue
shorter, longer = sorted([first_similar_new, first_similar_existing], key=len)
# Warn user about potential conflict and ask for confirmation
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Potential Name Conflict")
msg_box.setText(
f"The name '{first_similar_new}' is very similar to an existing name: '{first_similar_existing}'.\n\n"
f"This could lead to files being grouped into less specific folders (e.g., under '{clean_folder_name(shorter)}' instead of a more specific '{clean_folder_name(longer)}').\n\n"
"Do you want to change the name you are adding, or proceed anyway?"
)
change_button = msg_box.addButton("Change Name", QMessageBox.RejectRole) # Option to change
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) # Option to proceed
msg_box.setDefaultButton(proceed_button) # Default to proceed
msg_box.setEscapeButton(change_button) # Escape cancels/changes
msg_box.exec_()
if msg_box.clickedButton() == change_button: # If user chose to change
self.log_signal.emit(f" User chose to change '{first_similar_new}' due to similarity with '{first_similar_existing}'.")
return False # Indicate user chose to change, so don't add this one
# If user chose to proceed, log it
self.log_signal.emit(f"⚠️ User proceeded with adding '{first_similar_new}' despite similarity with '{first_similar_existing}'.")
# If no conflict or user chose to proceed, add the name to KNOWN_NAMES
KNOWN_NAMES.append(name_to_add)
KNOWN_NAMES.sort(key=str.lower) # Keep the list sorted case-insensitively
# Update UI list (QListWidget)
self.character_list.clear()
self.character_list.addItems(KNOWN_NAMES)
self.filter_character_list(self.character_search_input.text()) # Re-apply search filter if any
self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list.")
self.new_char_input.clear() # Clear input field after adding
self.save_known_names() # Persist changes to the config file
return True # Indicate success
def delete_selected_character(self):
"""Deletes selected character/show names from the known list and UI."""
global KNOWN_NAMES # Ensure we use the potentially shared KNOWN_NAMES
selected_items = self.character_list.selectedItems() # Get selected items from QListWidget
if not selected_items: # If no items selected
QMessageBox.warning(self, "Selection Error", "Please select one or more names to delete."); return
names_to_remove = {item.text() for item in selected_items} # Get unique names to remove
# Confirm deletion with the user
confirm = QMessageBox.question(self, "Confirm Deletion",
f"Are you sure you want to delete {len(names_to_remove)} name(s)?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No) # Default to No
if confirm == QMessageBox.Yes:
original_count = len(KNOWN_NAMES)
# Filter out the names to remove from KNOWN_NAMES (modify in-place)
KNOWN_NAMES[:] = [n for n in KNOWN_NAMES if n not in names_to_remove]
removed_count = original_count - len(KNOWN_NAMES)
if removed_count > 0: # If names were actually removed
self.log_signal.emit(f"🗑️ Removed {removed_count} name(s).")
# Update UI list
self.character_list.clear()
self.character_list.addItems(KNOWN_NAMES)
self.filter_character_list(self.character_search_input.text()) # Re-apply search filter
self.save_known_names() # Persist changes to config file
else: # Should not happen if items were selected, but good to handle
self.log_signal.emit(" No names were removed (they might not have been in the list).")
def update_custom_folder_visibility(self, url_text=None):
"""Shows or hides the custom folder input based on URL type (single post) and subfolder settings."""
if url_text is None: # If called without arg (e.g., from other UI changes that affect this)
url_text = self.link_input.text() # Get current URL from input
_, _, post_id = extract_post_info(url_text.strip()) # Check if it's a single post URL
is_single_post_url = bool(post_id) # True if a post ID was extracted
# Subfolders must be generally enabled for custom folder to be relevant
subfolders_enabled = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
# Custom folder input is NOT relevant if in "Only Links" or "Only Archives" mode,
# as these modes might not use folder structures in the same way or at all.
not_only_links_or_archives_mode = not (
(self.radio_only_links and self.radio_only_links.isChecked()) or
(self.radio_only_archives and self.radio_only_archives.isChecked())
)
# Show custom folder input if all conditions are met:
# 1. It's a single post URL.
# 2. "Separate Folders by Name/Title" (main subfolder option) is checked.
# 3. It's NOT "Only Links" or "Only Archives" mode.
should_show_custom_folder = is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode
if self.custom_folder_widget: # Ensure custom folder widget exists
self.custom_folder_widget.setVisible(should_show_custom_folder) # Set visibility
# If the custom folder input is hidden, clear its content
if not (self.custom_folder_widget and self.custom_folder_widget.isVisible()):
if self.custom_folder_input: self.custom_folder_input.clear()
def update_ui_for_subfolders(self, checked):
"""Updates UI elements related to subfolder settings (character filter, per-post subfolder checkbox)."""
# "Only Links" and "Only Archives" modes generally don't use character-based subfolders or per-post subfolders.
is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked()
# Character filter and per-post subfolder options are relevant if:
# 1. The main "Separate Folders by Name/Title" (passed as 'checked' arg) is ON.
# 2. It's NOT "Only Links" mode AND NOT "Only Archives" mode.
enable_char_and_post_subfolder_options = checked and not is_only_links and not is_only_archives
# Character filter widget visibility
if self.character_filter_widget: # Ensure widget exists
self.character_filter_widget.setVisible(enable_char_and_post_subfolder_options)
if not self.character_filter_widget.isVisible() and self.character_input:
self.character_input.clear() # Clear character input if hidden
# "Subfolder per Post" checkbox enabled state
if self.use_subfolder_per_post_checkbox: # Ensure checkbox exists
self.use_subfolder_per_post_checkbox.setEnabled(enable_char_and_post_subfolder_options)
if not enable_char_and_post_subfolder_options: # If disabled by current conditions
self.use_subfolder_per_post_checkbox.setChecked(False) # Also uncheck it
# Update custom folder visibility, as it depends on subfolder settings too
self.update_custom_folder_visibility()
def update_page_range_enabled_state(self):
"""Enables/disables page range inputs based on URL type (creator feed vs single post) and Manga Mode."""
url_text = self.link_input.text().strip() if self.link_input else ""
_, _, post_id = extract_post_info(url_text) # Check if it's a single post URL
is_creator_feed = not post_id if url_text else False # True if URL is present and not a post URL
# Manga mode overrides page range (downloads all posts, sorted oldest first)
manga_mode_active = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
# Enable page range if it's a creator feed AND Manga Mode is OFF
enable_page_range = is_creator_feed and not manga_mode_active
# Enable/disable page range UI elements
for widget in [self.page_range_label, self.start_page_input, self.to_label, self.end_page_input]:
if widget: widget.setEnabled(enable_page_range)
# If page range is disabled, clear the input fields
if not enable_page_range:
if self.start_page_input: self.start_page_input.clear()
if self.end_page_input: self.end_page_input.clear()
def _update_manga_filename_style_button_text(self):
"""Updates the text and tooltip of the manga filename style toggle button based on current style."""
if self.manga_rename_toggle_button: # Ensure button exists
if self.manga_filename_style == STYLE_POST_TITLE:
self.manga_rename_toggle_button.setText("Name: Post Title")
self.manga_rename_toggle_button.setToolTip(
"Manga files: First file named by post title. Subsequent files in same post keep original names.\n"
"Click to change to original file names for all files."
)
elif self.manga_filename_style == STYLE_ORIGINAL_NAME:
self.manga_rename_toggle_button.setText("Name: Original File")
self.manga_rename_toggle_button.setToolTip(
"Manga files will keep their original names as provided by the site (e.g., 001.jpg, page_01.png).\n"
"Click to change to post title based naming for the first file."
)
else: # Fallback for unknown style (should not happen)
self.manga_rename_toggle_button.setText("Name: Unknown Style")
self.manga_rename_toggle_button.setToolTip("Manga filename style is in an unknown state.")
def _toggle_manga_filename_style(self):
"""Toggles the manga filename style between 'post_title' and 'original_name', updates UI and settings."""
current_style = self.manga_filename_style
new_style = ""
if current_style == STYLE_POST_TITLE: # If current is Post Title, switch to Original Name
new_style = STYLE_ORIGINAL_NAME
# Optional: Warn user if they switch away from the recommended style for manga
reply = QMessageBox.information(self, "Manga Filename Preference",
"Using 'Name: Post Title' (first file by title, others original) is recommended for Manga Mode.\n\n"
"Using 'Name: Original File' for all files might lead to less organized downloads if original names are inconsistent or non-sequential.\n\n"
"Proceed with using 'Name: Original File' for all files?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No) # Default to No
if reply == QMessageBox.No: # If user cancels the change
self.log_signal.emit(" Manga filename style change to 'Original File' cancelled by user.")
return # Don't change if user cancels
elif current_style == STYLE_ORIGINAL_NAME: # If current is Original Name, switch to Post Title
new_style = STYLE_POST_TITLE
else: # If current style is unknown (e.g., corrupted setting), reset to default
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 # Update internal attribute
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style) # Save new style to settings
self.settings.sync() # Ensure setting is written to disk
self._update_manga_filename_style_button_text() # Update button UI text and tooltip
self.log_signal.emit(f" Manga filename style changed to: '{self.manga_filename_style}'") # Log the change
def update_ui_for_manga_mode(self, checked): # 'checked' is the state of the manga_mode_checkbox
"""Updates UI elements based on Manga Mode state (checkbox state and URL type)."""
url_text = self.link_input.text().strip() if self.link_input else ""
_, _, post_id = extract_post_info(url_text) # Check if it's a single post URL
# Manga mode is only applicable to creator feeds (not single posts)
is_creator_feed = not post_id if url_text else False
# Enable/disable the Manga Mode checkbox itself based on whether it's a creator feed
if self.manga_mode_checkbox: # Ensure checkbox exists
self.manga_mode_checkbox.setEnabled(is_creator_feed)
if not is_creator_feed and self.manga_mode_checkbox.isChecked(): # If URL changes to single post, uncheck manga mode
self.manga_mode_checkbox.setChecked(False)
# 'checked' variable (passed in) might now be stale, so re-evaluate based on checkbox's current state
checked = self.manga_mode_checkbox.isChecked()
# Manga mode is effectively ON if the checkbox is checked AND it's a creator feed
manga_mode_effectively_on = is_creator_feed and checked # Use the potentially updated 'checked' value
# Show/hide the manga filename style toggle button
if self.manga_rename_toggle_button: # Ensure button exists
self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on)
# If manga mode is on, page range is disabled (as it downloads all posts, sorted)
if manga_mode_effectively_on:
if self.page_range_label: self.page_range_label.setEnabled(False)
if self.start_page_input: self.start_page_input.setEnabled(False); self.start_page_input.clear()
if self.to_label: self.to_label.setEnabled(False)
if self.end_page_input: self.end_page_input.setEnabled(False); self.end_page_input.clear()
else: # If manga mode is off (or not applicable), re-evaluate page range normally
self.update_page_range_enabled_state()
def filter_character_list(self, search_text):
"""Filters the QListWidget of known characters based on the provided search text."""
search_text_lower = search_text.lower() # For case-insensitive search
for i in range(self.character_list.count()): # Iterate through all items in the list
item = self.character_list.item(i)
# Hide item if search text is not in item text (case-insensitive)
item.setHidden(search_text_lower not in item.text().lower())
def update_multithreading_label(self, text): # 'text' is the current text of thread_count_input
"""Updates the multithreading checkbox text to show the current thread count if enabled."""
if self.use_multithreading_checkbox.isChecked(): # If multithreading is enabled
try:
num_threads_val = int(text) # Convert input text to integer
if num_threads_val > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads_val} Threads)")
else: self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)") # Should be caught by validator
except ValueError: # If text is not a valid integer
self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)")
else: # If multithreading is unchecked, it implies 1 thread (main thread operation)
self.use_multithreading_checkbox.setText("Use Multithreading (1 Thread)")
def _handle_multithreading_toggle(self, checked): # 'checked' is the state of use_multithreading_checkbox
"""Enables/disables the thread count input based on the multithreading checkbox state."""
if not checked: # Multithreading disabled (checkbox unchecked)
self.thread_count_input.setEnabled(False) # Disable thread count input
self.thread_count_label.setEnabled(False) # Disable thread count label
# Update checkbox text to reflect single-threaded operation
self.use_multithreading_checkbox.setText("Use Multithreading (1 Thread)")
else: # Multithreading enabled (checkbox checked)
self.thread_count_input.setEnabled(True) # Enable thread count input
self.thread_count_label.setEnabled(True) # Enable thread count label
# Update checkbox text based on current value in thread_count_input
self.update_multithreading_label(self.thread_count_input.text())
def update_progress_display(self, total_posts, processed_posts):
"""Updates the overall progress label in the UI."""
if total_posts > 0: # If total number of posts is known
progress_percent = (processed_posts / total_posts) * 100
self.progress_label.setText(f"Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)")
elif processed_posts > 0 : # If total is unknown but some posts are processed (e.g., single post mode)
self.progress_label.setText(f"Progress: Processing post {processed_posts}...")
else: # Initial state or no posts found yet
self.progress_label.setText("Progress: Starting...")
# Clear individual file progress when overall progress updates (unless it's a clear signal for file progress)
if total_posts > 0 or processed_posts > 0 :
self.file_progress_label.setText("") # Clear individual file progress label
def start_download(self):
"""Initiates the download process based on current UI settings and validations."""
# Ensure access to global/utility functions and classes from downloader_utils
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER
if self._is_download_active(): # Prevent multiple concurrent downloads from starting
QMessageBox.warning(self, "Busy", "A download is already running."); return
# --- Gather all settings from UI ---
api_url = self.link_input.text().strip()
output_dir = self.dir_input.text().strip()
use_subfolders = self.use_subfolders_checkbox.isChecked()
# Per-post subfolders only make sense if main subfolders are also enabled
use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked() and use_subfolders
compress_images = self.compress_images_checkbox.isChecked()
download_thumbnails = self.download_thumbnails_checkbox.isChecked()
use_multithreading_enabled_by_checkbox = self.use_multithreading_checkbox.isChecked()
try: # Get and validate thread count from GUI
num_threads_from_gui = int(self.thread_count_input.text().strip())
if num_threads_from_gui < 1: num_threads_from_gui = 1 # Ensure at least 1 thread
except ValueError: # If input is not a valid integer
QMessageBox.critical(self, "Thread Count Error", "Invalid number of threads. Please enter a positive number.")
self.set_ui_enabled(True) # Re-enable UI if error occurs before download starts
return
raw_skip_words = self.skip_words_input.text().strip() # Get raw skip words string
# Parse skip words into a list of lowercase, stripped words
skip_words_list = [word.strip().lower() for word in raw_skip_words.split(',') if word.strip()]
current_skip_words_scope = self.get_skip_words_scope() # Get current scope for skip words
manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
# Determine filter mode and if only links are being extracted
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
backend_filter_mode = self.get_filter_mode() # This will be 'archive' if that radio button is selected
# Get text of the selected filter radio button for logging purposes
user_selected_filter_text = self.radio_group.checkedButton().text() if self.radio_group.checkedButton() else "All"
# Determine effective skip_zip and skip_rar based on the selected filter mode
# If "Only Archives" mode is selected, we want to download archives, so skip flags must be False.
if backend_filter_mode == 'archive':
effective_skip_zip = False
effective_skip_rar = False
else: # For other modes (All, Images, Videos, Only Links), respect the checkbox states
effective_skip_zip = self.skip_zip_checkbox.isChecked()
effective_skip_rar = self.skip_rar_checkbox.isChecked()
# --- Validations ---
if not api_url: QMessageBox.critical(self, "Input Error", "URL is required."); return
# Output directory is required unless only extracting links
if not extract_links_only and not output_dir:
QMessageBox.critical(self, "Input Error", "Download Directory is required when not in 'Only Links' mode."); return
service, user_id, post_id_from_url = extract_post_info(api_url) # Extract info from URL
if not service or not user_id: # Basic URL validation (must have service and user ID)
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format."); return
# Create output directory if it doesn't exist (and not in links-only mode)
if not extract_links_only and not os.path.isdir(output_dir):
reply = QMessageBox.question(self, "Create Directory?",
f"The directory '{output_dir}' does not exist.\nCreate it now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) # Default to Yes
if reply == QMessageBox.Yes:
try: os.makedirs(output_dir, exist_ok=True); self.log_signal.emit(f" Created directory: {output_dir}")
except Exception as e: QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}"); return
else: self.log_signal.emit("❌ Download cancelled: Output directory does not exist and was not created."); return
# Check for Pillow library if image compression is enabled
if compress_images and Image is None: # Image is None if Pillow import failed
QMessageBox.warning(self, "Missing Dependency", "Pillow library (for image compression) not found. Compression will be disabled.")
compress_images = False; self.compress_images_checkbox.setChecked(False) # Update UI and flag
# Manga mode is only applicable for creator feeds (not single posts)
manga_mode = manga_mode_is_checked and not post_id_from_url
# Page range validation (only if not manga mode and it's a creator feed)
start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip()
start_page, end_page = None, None # Initialize to None
is_creator_feed = bool(not post_id_from_url) # True if URL is present and not a single post URL
if is_creator_feed and not manga_mode: # Page range applies only to creator feeds not in manga mode
try: # Validate page range inputs
if start_page_str: start_page = int(start_page_str)
if end_page_str: end_page = int(end_page_str)
if start_page is not None and start_page <= 0: raise ValueError("Start page must be positive.")
if end_page is not None and end_page <= 0: raise ValueError("End page must be positive.")
if start_page and end_page and start_page > end_page: raise ValueError("Start page cannot be greater than end page.")
except ValueError as e: QMessageBox.critical(self, "Page Range Error", f"Invalid page range: {e}"); return
elif manga_mode: # In manga mode, ignore page range inputs (downloads all)
start_page, end_page = None, None
# --- Reset state for new download ---
self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None
self.all_kept_original_filenames = [] # Reset list of filenames that kept their original names
# Character filter validation and prompt (if subfolders enabled and not links only mode)
raw_character_filters_text = self.character_input.text().strip()
# Parse character filters from comma-separated string
parsed_character_list = [name.strip() for name in raw_character_filters_text.split(',') if name.strip()] if raw_character_filters_text else None
filter_character_list_to_pass = None # This will be passed to the backend download logic
# Validate character filters if subfolders are used, it's a creator feed, and not extracting only links
if use_subfolders and parsed_character_list and not post_id_from_url and not extract_links_only:
self.log_signal.emit(f" Validating character filters for subfolder naming: {', '.join(parsed_character_list)}")
valid_filters_for_backend = [] # List of filters confirmed to be valid
user_cancelled_validation = False # Flag if user cancels during validation
for char_name in parsed_character_list:
cleaned_name_test = clean_folder_name(char_name) # Test if name is valid for a folder name
if not cleaned_name_test: # If cleaning results in empty or invalid name
QMessageBox.warning(self, "Invalid Filter Name", f"Filter name '{char_name}' is invalid for a folder and will be skipped.")
self.log_signal.emit(f"⚠️ Skipping invalid filter for folder: '{char_name}'"); continue
# Check if name is in known list (Known.txt), prompt to add if not
if char_name.lower() not in {kn.lower() for kn in KNOWN_NAMES}:
reply = QMessageBox.question(self, "Add Filter Name to Known List?",
f"Filter '{char_name}' is not in known names list.\nAdd it now?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes)
if reply == QMessageBox.Yes: # User wants to add
self.new_char_input.setText(char_name) # Pre-fill input for user convenience
if self.add_new_character(): # Try to add it (this calls save_known_names)
self.log_signal.emit(f"✅ Added '{char_name}' to known names via filter prompt.")
valid_filters_for_backend.append(char_name) # Add to list to pass if successful
else: # Add failed (e.g., user cancelled sub-prompt or conflict during add_new_character)
self.log_signal.emit(f"⚠️ Failed to add '{char_name}' via filter prompt (might have been a conflict or cancellation).")
# Still add if it was a valid folder name, even if not added to known list, for this run
if cleaned_name_test: valid_filters_for_backend.append(char_name)
elif reply == QMessageBox.Cancel: # User cancelled the whole download process
self.log_signal.emit(f"❌ Download cancelled during filter validation for '{char_name}'."); user_cancelled_validation = True; break
else: # User chose No (don't add to known list, but proceed with filter for this run)
self.log_signal.emit(f" Proceeding with filter '{char_name}' without adding to known list.")
if cleaned_name_test: valid_filters_for_backend.append(char_name) # Add if valid folder name
else: # Already in known list
if cleaned_name_test: valid_filters_for_backend.append(char_name) # Add if valid folder name
if user_cancelled_validation: return # Stop if user cancelled during prompt
if valid_filters_for_backend: # If there are valid filters after validation
filter_character_list_to_pass = valid_filters_for_backend
self.log_signal.emit(f" Using validated character filters for subfolders: {', '.join(filter_character_list_to_pass)}")
else: # If no valid filters remain
self.log_signal.emit("⚠️ No valid character filters remaining for subfolder naming (after validation).")
elif parsed_character_list : # If not using subfolders or it's a single post, still pass the list for other filtering purposes (e.g., file content filtering)
filter_character_list_to_pass = parsed_character_list
self.log_signal.emit(f" Character filters provided: {', '.join(filter_character_list_to_pass)} (Subfolder rules may differ or not apply).")
# Manga mode warning if no character filter is provided (as filter is used for naming/folder)
if manga_mode and not filter_character_list_to_pass and not extract_links_only:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Manga Mode Filter Warning")
msg_box.setText(
"Manga Mode is enabled, but 'Filter by Character(s)' is empty.\n\n"
"For best results (correct file naming and folder organization if subfolders are on), "
"please enter the Manga/Series title into the filter field.\n\n"
"Proceed without a filter (names might be generic, folder might be less specific)?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
msg_box.exec_()
if msg_box.clickedButton() == cancel_button: # If user cancels
self.log_signal.emit("❌ Download cancelled due to Manga Mode filter warning."); return
else: # User proceeds
self.log_signal.emit("⚠️ Proceeding with Manga Mode without a specific title filter.")
# Custom folder name for single post downloads
custom_folder_name_cleaned = None # Initialize
# Check if custom folder input is relevant and visible
if use_subfolders and post_id_from_url and self.custom_folder_widget and self.custom_folder_widget.isVisible() and not extract_links_only:
raw_custom_name = self.custom_folder_input.text().strip() # Get raw custom folder name
if raw_custom_name: # If a name was provided
cleaned_custom = clean_folder_name(raw_custom_name) # Clean it for folder usage
if cleaned_custom: custom_folder_name_cleaned = cleaned_custom # Use if valid
else: self.log_signal.emit(f"⚠️ Invalid custom folder name ignored: '{raw_custom_name}' (resulted in empty string after cleaning).")
# --- Clear logs and reset progress counters ---
self.main_log_output.clear() # Clear main log
if extract_links_only: self.main_log_output.append("🔗 Extracting Links..."); # Initial message for links mode
elif backend_filter_mode == 'archive': self.main_log_output.append("📦 Downloading Archives Only...") # Log for new archive mode
if self.external_log_output: self.external_log_output.clear() # Clear external log
# Show external log title only if it's relevant for the current mode and setting
if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive':
self.external_log_output.append("🔗 External Links Found:")
self.file_progress_label.setText(""); self.cancellation_event.clear(); self.active_futures = [] # Reset progress and cancellation
self.total_posts_to_process = self.processed_posts_count = self.download_counter = self.skip_counter = 0 # Reset counters
self.progress_label.setText("Progress: Initializing...") # Initial progress message
# Determine effective number of threads for posts and files based on settings
effective_num_post_workers = 1 # Default for single post or non-multithreaded creator feed
effective_num_file_threads_per_worker = 1 # Default number of file download threads per worker
if post_id_from_url: # Single post URL
if use_multithreading_enabled_by_checkbox: # Use GUI thread count for file downloads for this single post
effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER))
else: # Creator feed URL
if use_multithreading_enabled_by_checkbox: # If multithreading is enabled for creator feed
effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS)) # For concurrent post processing
# The same GUI thread count is also used as the *max* for files per worker, capped appropriately
effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER))
# --- Log initial download parameters to the main log ---
log_messages = ["="*40, f"🚀 Starting {'Link Extraction' if extract_links_only else ('Archive Download' if backend_filter_mode == 'archive' else 'Download')} @ {time.strftime('%Y-%m-%d %H:%M:%S')}", f" URL: {api_url}"]
if not extract_links_only: log_messages.append(f" Save Location: {output_dir}")
if post_id_from_url: # Logging for Single Post download
log_messages.append(f" Mode: Single Post")
log_messages.append(f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker} concurrent file(s)")
else: # Logging for Creator Feed download
log_messages.append(f" Mode: Creator Feed")
log_messages.append(f" Post Processing: {'Multi-threaded (' + str(effective_num_post_workers) + ' workers)' if effective_num_post_workers > 1 else 'Single-threaded (1 worker)'}")
log_messages.append(f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker} concurrent file(s)")
if is_creator_feed: # Only log page range for creator feeds
if manga_mode: log_messages.append(" Page Range: All (Manga Mode - Oldest Posts Processed First)")
else: # Construct a readable page range string for logging
pr_log = "All" # Default if no pages specified
if start_page or end_page:
pr_log = f"{f'From {start_page} ' if start_page else ''}{'to ' if start_page and end_page else ''}{f'{end_page}' if end_page else (f'Up to {end_page}' if end_page else (f'From {start_page}' if start_page else 'Specific Range'))}".strip()
log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}")
if not extract_links_only: # Settings relevant to file downloading
log_messages.append(f" Subfolders: {'Enabled' if use_subfolders else 'Disabled'}")
if use_subfolders: # Log subfolder naming details
if custom_folder_name_cleaned: log_messages.append(f" Custom Folder (Post): '{custom_folder_name_cleaned}'")
elif filter_character_list_to_pass and not post_id_from_url: log_messages.append(f" Character Filters for Folders: {', '.join(filter_character_list_to_pass)}")
else: log_messages.append(f" Folder Naming: Automatic (based on title/known names)")
log_messages.append(f" Subfolder per Post: {'Enabled' if use_post_subfolders else 'Disabled'}")
log_messages.extend([
f" File Type Filter: {user_selected_filter_text} (Backend processing as: {backend_filter_mode})",
f" Skip Archives: {'.zip' if effective_skip_zip else ''}{', ' if effective_skip_zip and effective_skip_rar else ''}{'.rar' if effective_skip_rar else ''}{'None (Archive Mode)' if backend_filter_mode == 'archive' else ('None' if not (effective_skip_zip or effective_skip_rar) else '')}", # Clarify for archive mode
f" Skip Words (posts/files): {', '.join(skip_words_list) if skip_words_list else 'None'}",
f" Skip Words Scope: {current_skip_words_scope.capitalize()}",
f" Compress Images: {'Enabled' if compress_images else 'Disabled'}",
f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}"
])
else: # Link extraction mode logging
log_messages.append(f" Mode: Extracting Links Only")
# Log external links setting (relevant unless in "Only Links" or "Only Archives" mode where it's forced off)
log_messages.append(f" Show External Links: {'Enabled' if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive' else 'Disabled'}")
if manga_mode: # Manga mode specific logs
log_messages.append(f" Manga Mode (File Renaming by Post Title): Enabled")
log_messages.append(f" ↳ Manga Filename Style: {'Post Title Based' if self.manga_filename_style == STYLE_POST_TITLE else 'Original File Name'}")
# Determine if multithreading for posts is actually used for logging
# It's used if checkbox is checked AND it's a creator feed (not single post)
should_use_multithreading_for_posts = use_multithreading_enabled_by_checkbox and not post_id_from_url
log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading_for_posts else 'Single-threaded (posts)'}")
if should_use_multithreading_for_posts: # Log number of post workers only if actually using them
log_messages.append(f" Number of Post Worker Threads: {effective_num_post_workers}")
log_messages.append("="*40) # End of parameter logging
for msg in log_messages: self.log_signal.emit(msg) # Emit all log messages
# --- Disable UI and prepare for download ---
self.set_ui_enabled(False) # Disable UI elements during download
unwanted_keywords_for_folders = {'spicy', 'hd', 'nsfw', '4k', 'preview', 'teaser', 'clip'} # Example set of keywords to avoid in folder names
# --- Prepare arguments dictionary for backend thread/worker ---
# This template holds all possible arguments that might be needed by either single or multi-threaded download logic
args_template = {
'api_url_input': api_url,
'download_root': output_dir, # Used by PostProcessorWorker if it creates folders
'output_dir': output_dir, # Passed to DownloadThread for consistency (though it might use download_root)
'known_names': list(KNOWN_NAMES), # Pass a copy of the current known names
'known_names_copy': list(KNOWN_NAMES), # Legacy, ensure it's there if used by older parts of backend
'filter_character_list': filter_character_list_to_pass,
'filter_mode': backend_filter_mode, # 'all', 'image', 'video', or 'archive'
'skip_zip': effective_skip_zip, # Use the determined effective value based on mode
'skip_rar': effective_skip_rar, # Use the determined effective value based on mode
'use_subfolders': use_subfolders,
'use_post_subfolders': use_post_subfolders,
'compress_images': compress_images,
'download_thumbnails': download_thumbnails,
'service': service, # Extracted from URL
'user_id': user_id, # Extracted from URL
'downloaded_files': self.downloaded_files, # Pass shared set for session-based skip
'downloaded_files_lock': self.downloaded_files_lock, # Pass shared lock
'downloaded_file_hashes': self.downloaded_file_hashes, # Pass shared set for hash-based skip
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, # Pass shared lock
'skip_words_list': skip_words_list,
'skip_words_scope': current_skip_words_scope,
'show_external_links': self.show_external_links, # For worker to know if it should emit external_link_signal
'extract_links_only': extract_links_only, # For worker to know if it should only extract links
'start_page': start_page, # Validated start page
'end_page': end_page, # Validated end page
'target_post_id_from_initial_url': post_id_from_url, # The specific post ID if a single post URL was given
'custom_folder_name': custom_folder_name_cleaned, # Cleaned custom folder name for single post
'manga_mode_active': manga_mode, # Flag for manga mode
'unwanted_keywords': unwanted_keywords_for_folders, # For folder naming logic in worker
'cancellation_event': self.cancellation_event, # Shared cancellation event for all threads/workers
'signals': self.worker_signals, # Signals object for PostProcessorWorker instances to communicate back to GUI
'manga_filename_style': self.manga_filename_style, # Current manga filename style
# Pass the effective number of file threads for the worker/post processor to use internally
'num_file_threads_for_worker': effective_num_file_threads_per_worker
}
# --- Start download (single-threaded for posts or multi-threaded for posts) ---
try:
if should_use_multithreading_for_posts: # Multi-threaded for posts (creator feed with multithreading enabled)
self.log_signal.emit(f" Initializing multi-threaded {'link extraction' if extract_links_only else 'download'} with {effective_num_post_workers} post workers...")
self.start_multi_threaded_download(num_post_workers=effective_num_post_workers, **args_template)
else: # Single-threaded for posts (either single post URL or creator feed with multithreading off)
self.log_signal.emit(f" Initializing single-threaded {'link extraction' if extract_links_only else 'download'}...")
# Define keys expected by BackendDownloadThread constructor for clarity and to avoid passing unexpected args
dt_expected_keys = [
'api_url_input', 'output_dir', 'known_names_copy', 'cancellation_event',
'filter_character_list', 'filter_mode', 'skip_zip', 'skip_rar',
'use_subfolders', 'use_post_subfolders', 'custom_folder_name',
'compress_images', 'download_thumbnails', 'service', 'user_id',
'downloaded_files', 'downloaded_file_hashes',
'downloaded_files_lock', 'downloaded_file_hashes_lock',
'skip_words_list', 'skip_words_scope', 'show_external_links', 'extract_links_only',
'num_file_threads_for_worker', # This is for the PostProcessorWorker that BackendDownloadThread might create
'skip_current_file_flag', # Event for skipping a single file (if feature existed)
'start_page', 'end_page', 'target_post_id_from_initial_url',
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style'
]
# For single threaded (post) download, the 'num_file_threads_for_worker' from args_template
# will be used by the PostProcessorWorker if it needs to download multiple files for that single post.
args_template['skip_current_file_flag'] = None # Ensure this is explicitly set (or passed if it were a feature)
# Filter args_template to only include keys expected by BackendDownloadThread constructor
single_thread_args = {key: args_template[key] for key in dt_expected_keys if key in args_template}
self.start_single_threaded_download(**single_thread_args) # Start the single download thread
except Exception as e: # Catch any errors during the preparation/start of download
self.log_signal.emit(f"❌ CRITICAL ERROR preparing download: {e}\n{traceback.format_exc()}")
QMessageBox.critical(self, "Start Error", f"Failed to start process:\n{e}")
self.download_finished(0,0,False, []) # Ensure UI is re-enabled and state is reset
def start_single_threaded_download(self, **kwargs):
"""Starts the download process in a single QThread (BackendDownloadThread).
This thread handles post fetching and then processes each post sequentially (though file downloads within a post can be multi-threaded by PostProcessorWorker).
"""
global BackendDownloadThread # The class imported from downloader_utils
try:
self.download_thread = BackendDownloadThread(**kwargs) # Instantiate with all necessary arguments
# Connect signals from the backend thread to GUI handler methods
if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.connect(self.handle_main_log)
if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.connect(self.add_character_prompt_signal)
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.connect(self.download_finished)
# For character prompt response flowing back from GUI to the backend thread
if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.connect(self.download_thread.receive_add_character_result)
if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.connect(self.handle_external_link_signal)
if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
self.download_thread.start() # Start the QThread
self.log_signal.emit("✅ Single download thread (for posts) started.")
except Exception as e: # Catch errors during thread instantiation or start
self.log_signal.emit(f"❌ CRITICAL ERROR starting single-thread: {e}\n{traceback.format_exc()}")
QMessageBox.critical(self, "Thread Start Error", f"Failed to start download process: {e}")
self.download_finished(0,0,False, []) # Ensure UI is re-enabled and state is reset
def start_multi_threaded_download(self, num_post_workers, **kwargs):
"""Starts the download process using a ThreadPoolExecutor for fetching and processing posts concurrently."""
global PostProcessorWorker # The worker class from downloader_utils
# Ensure thread pool is created if it doesn't exist or was previously shut down
if self.thread_pool is None:
self.thread_pool = ThreadPoolExecutor(max_workers=num_post_workers, thread_name_prefix='PostWorker_')
self.active_futures = [] # Reset list of active futures for this download run
# Reset progress counters for this run
self.processed_posts_count = 0; self.total_posts_to_process = 0; self.download_counter = 0; self.skip_counter = 0
self.all_kept_original_filenames = [] # Reset list of kept original filenames for this run
# 'num_file_threads_for_worker' is already in kwargs from the main start_download logic.
# This will be passed to each PostProcessorWorker instance created by _fetch_and_queue_posts.
# Start a separate Python thread (not QThread) to fetch post data and submit tasks to the pool.
# This prevents the GUI from freezing during the initial API calls to get all post data,
# especially for large creator feeds.
fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts, # Method to run in the new thread
args=(kwargs['api_url_input'], kwargs, num_post_workers), # Pass API URL, base args, and worker count
daemon=True, # Daemon thread will exit when the main application exits
name="PostFetcher" # Name for the thread (useful for debugging)
)
fetcher_thread.start() # Start the fetcher thread
self.log_signal.emit(f"✅ Post fetcher thread started. {num_post_workers} post worker threads initializing...")
def _fetch_and_queue_posts(self, api_url_input_for_fetcher, worker_args_template, num_post_workers):
"""
(This method runs in a separate Python thread, not the main GUI thread)
Fetches all post data using download_from_api and submits each post as a task to the ThreadPoolExecutor.
"""
global PostProcessorWorker, download_from_api # Ensure access to these from downloader_utils
all_posts_data = [] # List to store all fetched post data
fetch_error_occurred = False # Flag to track if an error occurs during fetching
manga_mode_active_for_fetch = worker_args_template.get('manga_mode_active', False) # Get manga mode status
# Ensure signals object is available for workers (it's created in DownloaderApp.__init__)
signals_for_worker = worker_args_template.get('signals')
if not signals_for_worker: # This should not happen if setup is correct
self.log_signal.emit("❌ CRITICAL ERROR: Signals object missing for worker in _fetch_and_queue_posts.");
self.finished_signal.emit(0,0,True, []); # Signal failure to GUI
return
try: # Fetch post data from API
self.log_signal.emit(" Fetching post data from API (this may take a moment for large feeds)...")
post_generator = download_from_api( # Call the API fetching function from downloader_utils
api_url_input_for_fetcher,
logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), # Prefix fetcher logs for clarity
start_page=worker_args_template.get('start_page'),
end_page=worker_args_template.get('end_page'),
manga_mode=manga_mode_active_for_fetch, # Pass manga mode for correct fetching order
cancellation_event=self.cancellation_event # Pass shared cancellation event
)
for posts_batch in post_generator: # download_from_api yields batches of posts
if self.cancellation_event.is_set(): # Check for cancellation
fetch_error_occurred = True; self.log_signal.emit(" Post fetching cancelled by user."); break
if isinstance(posts_batch, list): # Ensure API returned a list
all_posts_data.extend(posts_batch) # Add fetched posts to the list
self.total_posts_to_process = len(all_posts_data) # Update total post count
# Log progress periodically for very large feeds to show activity
if self.total_posts_to_process > 0 and self.total_posts_to_process % 100 == 0 : # e.g., log every 100 posts
self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts so far...")
else: # Should not happen if download_from_api is implemented correctly
fetch_error_occurred = True; self.log_signal.emit(f"❌ API fetcher returned non-list type: {type(posts_batch)}"); break
if not fetch_error_occurred and not self.cancellation_event.is_set(): # If fetching completed without error/cancellation
self.log_signal.emit(f"✅ Post fetching complete. Total posts to process: {self.total_posts_to_process}")
except TypeError as te: # Error in calling download_from_api (e.g., wrong arguments)
self.log_signal.emit(f"❌ TypeError calling download_from_api: {te}\n Check 'downloader_utils.py' signature.\n{traceback.format_exc(limit=2)}"); fetch_error_occurred = True
except RuntimeError as re_err: # Typically from cancellation within fetch_posts_paginated or API errors
self.log_signal.emit(f" Post fetching runtime error (likely cancellation or API issue): {re_err}"); fetch_error_occurred = True
except Exception as e: # Other unexpected errors during fetching
self.log_signal.emit(f"❌ Error during post fetching: {e}\n{traceback.format_exc(limit=2)}"); fetch_error_occurred = True
if self.cancellation_event.is_set() or fetch_error_occurred:
# If fetching was cancelled or failed, signal completion to GUI and clean up thread pool
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames)
if self.thread_pool: self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None # Don't wait if already cancelling
return
if self.total_posts_to_process == 0: # No posts found or fetched
self.log_signal.emit("😕 No posts found or fetched to process.");
self.finished_signal.emit(0,0,False, []); # Signal completion with zero counts
return
# --- Submit fetched posts to the thread pool for processing ---
self.log_signal.emit(f" Submitting {self.total_posts_to_process} post processing tasks to thread pool...")
self.processed_posts_count = 0 # Reset counter for this run
self.overall_progress_signal.emit(self.total_posts_to_process, 0) # Update GUI progress bar/label
# 'num_file_threads_for_worker' should be in worker_args_template from start_download,
# this is the number of file download threads each PostProcessorWorker will use.
num_file_dl_threads_for_each_worker = worker_args_template.get('num_file_threads_for_worker', 1)
# Define keys expected by PostProcessorWorker constructor for clarity and safety when preparing arguments
ppw_expected_keys = [
'post_data', 'download_root', 'known_names', 'filter_character_list', 'unwanted_keywords',
'filter_mode', 'skip_zip', 'skip_rar', 'use_subfolders', 'use_post_subfolders',
'target_post_id_from_initial_url', 'custom_folder_name', 'compress_images',
'download_thumbnails', 'service', 'user_id', 'api_url_input',
'cancellation_event', 'signals', 'downloaded_files', 'downloaded_file_hashes',
'downloaded_files_lock', 'downloaded_file_hashes_lock',
'skip_words_list', 'skip_words_scope', 'show_external_links', 'extract_links_only',
'num_file_threads', # This will be num_file_dl_threads_for_each_worker for the worker's internal pool
'skip_current_file_flag', # Event for skipping a single file within a worker (if feature existed)
'manga_mode_active', 'manga_filename_style'
]
# Keys that are optional for PostProcessorWorker or have defaults defined there
ppw_optional_keys_with_defaults = {
'skip_words_list', 'skip_words_scope', 'show_external_links', 'extract_links_only',
'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style'
# Note: 'unwanted_keywords' also has a default in the worker if not provided in args
}
for post_data_item in all_posts_data: # Iterate through each fetched post data
if self.cancellation_event.is_set(): break # Stop submitting new tasks if cancellation is requested
if not isinstance(post_data_item, dict): # Sanity check on post data type
self.log_signal.emit(f"⚠️ Skipping invalid post data item (not a dict): {type(post_data_item)}");
self.processed_posts_count += 1; # Count as processed to not hang progress if this happens
continue
# Prepare arguments for this specific PostProcessorWorker instance
worker_init_args = {}; missing_keys = [] # To store args for worker and track any missing ones
for key in ppw_expected_keys: # Iterate through expected keys for the worker
if key == 'post_data': worker_init_args[key] = post_data_item # Set the current post's data
elif key == 'num_file_threads': worker_init_args[key] = num_file_dl_threads_for_each_worker # Set file threads for this worker
elif key == 'signals': worker_init_args[key] = signals_for_worker # Use the shared signals object for this batch of workers
elif key in worker_args_template: worker_init_args[key] = worker_args_template[key] # Get from template if available
elif key in ppw_optional_keys_with_defaults: pass # Worker has a default, so no need to pass if not in template
else: missing_keys.append(key) # Should not happen if ppw_expected_keys is correct and covers all mandatory args
if missing_keys: # If any mandatory arguments are missing
self.log_signal.emit(f"❌ CRITICAL ERROR: Missing keys for PostProcessorWorker: {', '.join(missing_keys)}");
self.cancellation_event.set(); break # Stop everything if critical args are missing
try: # Submit the worker task to the thread pool
worker_instance = PostProcessorWorker(**worker_init_args) # Create worker instance
if self.thread_pool: # Ensure pool still exists and is active
future = self.thread_pool.submit(worker_instance.process) # Submit the worker's process method as a task
future.add_done_callback(self._handle_future_result) # Add callback for when this task finishes
self.active_futures.append(future) # Keep track of the submitted future
else: # Pool was shut down or never created (should not happen if logic is correct)
self.log_signal.emit("⚠️ Thread pool not available. Cannot submit more tasks."); break
except TypeError as te: self.log_signal.emit(f"❌ TypeError creating PostProcessorWorker: {te}\n Passed Args: [{', '.join(sorted(worker_init_args.keys()))}]\n{traceback.format_exc(limit=5)}"); self.cancellation_event.set(); break
except RuntimeError: self.log_signal.emit("⚠️ Runtime error submitting task (pool likely shutting down)."); break
except Exception as e: self.log_signal.emit(f"❌ Error submitting post {post_data_item.get('id','N/A')} to worker: {e}"); break
if not self.cancellation_event.is_set(): self.log_signal.emit(f" {len(self.active_futures)} post processing tasks submitted to pool.")
else:
self.finished_signal.emit(self.download_counter, self.skip_counter, True, self.all_kept_original_filenames)
if self.thread_pool: self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None
def _handle_future_result(self, future: Future):
self.processed_posts_count += 1
downloaded_files_from_future, skipped_files_from_future = 0, 0
kept_originals_from_future = []
try:
if future.cancelled(): self.log_signal.emit(" A post processing task was cancelled.")
elif future.exception(): self.log_signal.emit(f"❌ Post processing worker error: {future.exception()}")
else:
downloaded_files_from_future, skipped_files_from_future, kept_originals_from_future = future.result()
with self.downloaded_files_lock:
self.download_counter += downloaded_files_from_future
self.skip_counter += skipped_files_from_future
if kept_originals_from_future:
self.all_kept_original_filenames.extend(kept_originals_from_future)
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
except Exception as e: self.log_signal.emit(f"❌ Error in _handle_future_result callback: {e}\n{traceback.format_exc(limit=2)}")
if self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process:
if all(f.done() for f in self.active_futures):
QApplication.processEvents()
self.log_signal.emit("🏁 All submitted post tasks have completed or failed.")
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames)
def set_ui_enabled(self, enabled):
widgets_to_toggle = [ self.download_btn, self.link_input, self.radio_all, self.radio_images, self.radio_videos, self.radio_only_links,
self.skip_zip_checkbox, self.skip_rar_checkbox, self.use_subfolders_checkbox, self.compress_images_checkbox,
self.download_thumbnails_checkbox, self.use_multithreading_checkbox, self.skip_words_input, self.character_search_input,
self.new_char_input, self.add_char_button, self.delete_char_button, self.start_page_input, self.end_page_input,
self.page_range_label, self.to_label, self.character_input, self.custom_folder_input, self.custom_folder_label,
self.reset_button, self.manga_mode_checkbox, self.manga_rename_toggle_button,
self.skip_scope_toggle_button # Ensure the new button is in this list
]
for widget in widgets_to_toggle:
if widget: widget.setEnabled(enabled)
if enabled:
# When re-enabling UI, ensure skip scope button is correctly enabled/disabled by _handle_filter_mode_change
self._handle_filter_mode_change(self.radio_group.checkedButton(), True)
# else: # When disabling, the loop above handles the skip_scope_toggle_button
if self.external_links_checkbox:
is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
self.external_links_checkbox.setEnabled(enabled and not is_only_links)
if self.log_verbosity_button: self.log_verbosity_button.setEnabled(True)
multithreading_currently_on = self.use_multithreading_checkbox.isChecked()
self.thread_count_input.setEnabled(enabled and multithreading_currently_on)
self.thread_count_label.setEnabled(enabled and multithreading_currently_on)
subfolders_currently_on = self.use_subfolders_checkbox.isChecked()
self.use_subfolder_per_post_checkbox.setEnabled(enabled and subfolders_currently_on)
self.cancel_btn.setEnabled(not enabled)
if enabled:
# _handle_filter_mode_change is already called above, which should handle the button's enabled state
self._handle_multithreading_toggle(multithreading_currently_on)
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
def cancel_download(self):
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..."); self.cancellation_event.set()
if self.download_thread and self.download_thread.isRunning(): self.download_thread.requestInterruption(); self.log_signal.emit(" Signaled single download thread to interrupt.")
if self.thread_pool: self.log_signal.emit(" Initiating immediate shutdown and cancellation of worker pool tasks..."); self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.external_link_queue.clear(); self._is_processing_external_link_queue = False; self._current_link_post_title = None
self.cancel_btn.setEnabled(False); self.progress_label.setText("Progress: Cancelling..."); self.file_progress_label.setText("")
def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None):
if kept_original_names_list is None:
kept_original_names_list = self.all_kept_original_filenames if hasattr(self, 'all_kept_original_filenames') else []
if kept_original_names_list is None:
kept_original_names_list = []
status_message = "Cancelled by user" if cancelled_by_user else "Finished"
summary_log = "="*40
summary_log += f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n"
summary_log += "="*40
self.log_signal.emit(summary_log)
if kept_original_names_list:
intro_msg = (
HTML_PREFIX +
"<p> The following files from multi-file manga posts "
"(after the first file) kept their <b>original names</b>:</p>"
)
self.log_signal.emit(intro_msg)
html_list_items = "<ul>"
for name in kept_original_names_list:
html_list_items += f"<li><b>{name}</b></li>"
html_list_items += "</ul>"
self.log_signal.emit(HTML_PREFIX + html_list_items)
self.log_signal.emit("="*40)
self.progress_label.setText(f"{status_message}: {total_downloaded} downloaded, {total_skipped} skipped."); self.file_progress_label.setText("")
if not cancelled_by_user: self._try_process_next_external_link()
if self.download_thread:
try:
if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log)
if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal)
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished)
if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result)
if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.disconnect(self.handle_external_link_signal)
if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.disconnect(self.update_file_progress_display)
except (TypeError, RuntimeError) as e: self.log_signal.emit(f" Note during single-thread signal disconnection: {e}")
self.download_thread = None
if self.thread_pool: self.log_signal.emit(" Ensuring worker thread pool is shut down..."); self.thread_pool.shutdown(wait=True, cancel_futures=True); self.thread_pool = None
self.active_futures = []
self.set_ui_enabled(True); self.cancel_btn.setEnabled(False)
def toggle_log_verbosity(self):
self.basic_log_mode = not self.basic_log_mode
if self.basic_log_mode: self.log_verbosity_button.setText("Show Full Log"); self.log_signal.emit("="*20 + " Basic Log Mode Enabled " + "="*20)
else: self.log_verbosity_button.setText("Show Basic Log"); self.log_signal.emit("="*20 + " Full Log Mode Enabled " + "="*20)
def reset_application_state(self):
if self._is_download_active(): QMessageBox.warning(self, "Reset Error", "Cannot reset while a download is in progress. Please cancel first."); return
self.log_signal.emit("🔄 Resetting application state to defaults..."); self._reset_ui_to_defaults()
self.main_log_output.clear(); self.external_log_output.clear()
if self.show_external_links and not (self.radio_only_links and self.radio_only_links.isChecked()): self.external_log_output.append("🔗 External Links Found:")
self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None
self.progress_label.setText("Progress: Idle"); self.file_progress_label.setText("")
with self.downloaded_files_lock: count = len(self.downloaded_files); self.downloaded_files.clear();
if count > 0: self.log_signal.emit(f" Cleared {count} downloaded filename(s) from session memory.")
with self.downloaded_file_hashes_lock: count = len(self.downloaded_file_hashes); self.downloaded_file_hashes.clear();
if count > 0: self.log_signal.emit(f" Cleared {count} downloaded file hash(es) from session memory.")
self.total_posts_to_process = 0; self.processed_posts_count = 0; self.download_counter = 0; self.skip_counter = 0
self.all_kept_original_filenames = []
self.cancellation_event.clear(); self.basic_log_mode = False
if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log")
self.manga_filename_style = STYLE_POST_TITLE
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.skip_words_scope = SKIP_SCOPE_FILES # Reset to default "Files"
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
self._update_skip_scope_button_text() # Update button text
self.settings.sync()
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
self.log_signal.emit("✅ Application reset complete.")
def _reset_ui_to_defaults(self):
self.link_input.clear(); self.dir_input.clear(); self.custom_folder_input.clear(); self.character_input.clear();
self.skip_words_input.clear(); self.start_page_input.clear(); self.end_page_input.clear(); self.new_char_input.clear();
self.character_search_input.clear(); self.thread_count_input.setText("4"); self.radio_all.setChecked(True);
self.skip_zip_checkbox.setChecked(True); self.skip_rar_checkbox.setChecked(True); self.download_thumbnails_checkbox.setChecked(False);
self.compress_images_checkbox.setChecked(False); self.use_subfolders_checkbox.setChecked(True);
self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True);
self.external_links_checkbox.setChecked(False)
if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False)
self.skip_words_scope = SKIP_SCOPE_FILES # Reset scope variable
self._update_skip_scope_button_text() # Update button text
self._handle_filter_mode_change(self.radio_all, True)
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
self.filter_character_list("")
self.download_btn.setEnabled(True); self.cancel_btn.setEnabled(False)
if self.reset_button: self.reset_button.setEnabled(True)
if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log")
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(False)
def prompt_add_character(self, character_name):
global KNOWN_NAMES
reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The name '{character_name}' was encountered or used as a filter.\nIt's not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
result = (reply == QMessageBox.Yes)
if result:
self.new_char_input.setText(character_name)
if self.add_new_character(): self.log_signal.emit(f"✅ Added '{character_name}' to known names via background prompt.")
else: result = False; self.log_signal.emit(f" Adding '{character_name}' via background prompt was declined or failed.")
self.character_prompt_response_signal.emit(result)
def receive_add_character_result(self, result):
with QMutexLocker(self.prompt_mutex): self._add_character_response = result
self.log_signal.emit(f" Main thread received character prompt response: {'Action resulted in addition/confirmation' if result else 'Action resulted in no addition/declined'}")
if __name__ == '__main__':
import traceback
try:
qt_app = QApplication(sys.argv)
if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS
else: base_dir = os.path.dirname(os.path.abspath(__file__))
icon_path = os.path.join(base_dir, 'Kemono.ico')
if os.path.exists(icon_path): qt_app.setWindowIcon(QIcon(icon_path))
else: print(f"Warning: Application icon 'Kemono.ico' not found at {icon_path}")
downloader_app_instance = DownloaderApp()
downloader_app_instance.show()
if TourDialog:
tour_result = TourDialog.run_tour_if_needed(downloader_app_instance)
if tour_result == QDialog.Accepted: print("Tour completed by user.")
elif tour_result == QDialog.Rejected: print("Tour skipped or was already shown.")
exit_code = qt_app.exec_()
print(f"Application finished with exit code: {exit_code}")
sys.exit(exit_code)
except SystemExit: pass
except Exception as e:
print("--- CRITICAL APPLICATION ERROR ---")
print(f"An unhandled exception occurred: {e}")
traceback.print_exc()
print("--- END CRITICAL ERROR ---")
sys.exit(1)