mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-17 15:36:51 +00:00
2237 lines
147 KiB
Python
2237 lines
147 KiB
Python
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)
|