1725 lines
95 KiB
Python
Raw Normal View History

2025-05-05 19:35:24 +05:30
import sys
import os
import time
import requests
import re
2025-05-06 22:08:27 +05:30
import threading
2025-05-08 19:49:50 +05:30
import queue # Standard library queue, not directly used for the new link queue
2025-05-06 22:49:19 +05:30
import hashlib
2025-05-08 19:49:50 +05:30
import http.client
import traceback
import random # <-- Import random for generating delays
from collections import deque # <-- Import deque for the link queue
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
from concurrent.futures import ThreadPoolExecutor, CancelledError, Future
from PyQt5.QtGui import (
QIcon,
QIntValidator
)
2025-05-05 19:35:24 +05:30
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget,
2025-05-08 19:49:50 +05:30
QRadioButton, QButtonGroup, QCheckBox, QSplitter, QSizePolicy
2025-05-05 19:35:24 +05:30
)
2025-05-08 19:49:50 +05:30
# Ensure QTimer is imported
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer
2025-05-05 19:35:24 +05:30
from urllib.parse import urlparse
2025-05-07 07:20:40 +05:30
2025-05-06 22:08:27 +05:30
try:
from PIL import Image
except ImportError:
2025-05-08 19:49:50 +05:30
Image = None
2025-05-06 22:08:27 +05:30
from io import BytesIO
2025-05-08 19:49:50 +05:30
# --- Import from downloader_utils ---
try:
print("Attempting to import from downloader_utils...")
# Assuming downloader_utils_link_text is the correct version
from downloader_utils import (
KNOWN_NAMES,
clean_folder_name,
extract_post_info,
download_from_api,
PostProcessorSignals,
PostProcessorWorker,
DownloadThread as BackendDownloadThread
)
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}")
# ... (rest of error handling as in your original file) ...
KNOWN_NAMES = []
PostProcessorSignals = QObject
PostProcessorWorker = object
BackendDownloadThread = QThread
def clean_folder_name(n): return str(n) # Fallback
def extract_post_info(u): return None, None, None
def download_from_api(*a, **k): yield []
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)
# --- End Import ---
2025-05-05 19:35:24 +05:30
2025-05-06 22:08:27 +05:30
class DownloaderApp(QWidget):
character_prompt_response_signal = pyqtSignal(bool)
log_signal = pyqtSignal(str)
add_character_prompt_signal = pyqtSignal(str)
2025-05-06 22:49:19 +05:30
overall_progress_signal = pyqtSignal(int, int)
finished_signal = pyqtSignal(int, int, bool)
2025-05-08 19:49:50 +05:30
# Signal now carries link_text (ensure this matches downloader_utils)
external_link_signal = pyqtSignal(str, str, str, str) # post_title, link_text, link_url, platform
file_progress_signal = pyqtSignal(str, int, int)
2025-05-05 19:35:24 +05:30
2025-05-06 22:08:27 +05:30
def __init__(self):
super().__init__()
self.config_file = "Known.txt"
self.download_thread = None
self.thread_pool = None
self.cancellation_event = threading.Event()
self.active_futures = []
self.total_posts_to_process = 0
self.processed_posts_count = 0
self.download_counter = 0
self.skip_counter = 0
2025-05-08 19:49:50 +05:30
self.worker_signals = PostProcessorSignals() # Instance of signals for multi-thread workers
2025-05-06 22:08:27 +05:30
self.prompt_mutex = QMutex()
self._add_character_response = None
2025-05-06 22:49:19 +05:30
self.downloaded_files = set()
self.downloaded_files_lock = threading.Lock()
self.downloaded_file_hashes = set()
self.downloaded_file_hashes_lock = threading.Lock()
2025-05-08 19:49:50 +05:30
# self.external_links = [] # This list seems unused now
self.show_external_links = False
# --- For sequential delayed link display ---
self.external_link_queue = deque()
self._is_processing_external_link_queue = False
# --- END ---
# --- ADDED: For Log Verbosity ---
self.basic_log_mode = False # Start with full log
self.log_verbosity_button = None
# --- END ADDED ---
self.main_log_output = None
self.external_log_output = None
self.log_splitter = None # This is the VERTICAL splitter for logs
self.main_splitter = None # This will be the main HORIZONTAL splitter
self.reset_button = None
self.manga_mode_checkbox = None
self.load_known_names_from_util()
self.setWindowTitle("Kemono Downloader v3.0.0")
self.setGeometry(150, 150, 1050, 820) # Initial size
2025-05-06 22:08:27 +05:30
self.setStyleSheet(self.get_dark_theme())
2025-05-06 22:49:19 +05:30
self.init_ui()
2025-05-06 22:08:27 +05:30
self._connect_signals()
self.log_signal.emit(" Local API server functionality has been removed.")
2025-05-08 19:49:50 +05:30
self.log_signal.emit(" 'Skip Current File' button has been removed.")
self.character_input.setToolTip("Enter one or more character names, separated by commas (e.g., yor, makima)")
2025-05-05 19:35:24 +05:30
2025-05-06 22:08:27 +05:30
def _connect_signals(self):
2025-05-08 19:49:50 +05:30
# Signals from the worker_signals object (used by PostProcessorWorker in multi-threaded mode)
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)
# Connect the external_link_signal from worker_signals to the queue handler
if hasattr(self.worker_signals, 'external_link_signal'):
self.worker_signals.external_link_signal.connect(self.handle_external_link_signal)
# App's own signals (some of which might be emitted by DownloadThread which then connects to these handlers)
self.log_signal.connect(self.handle_main_log)
2025-05-06 22:08:27 +05:30
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)
2025-05-08 19:49:50 +05:30
# Connect the app's external_link_signal also to the queue handler
self.external_link_signal.connect(self.handle_external_link_signal)
self.file_progress_signal.connect(self.update_file_progress_display)
2025-05-06 22:49:19 +05:30
self.character_search_input.textChanged.connect(self.filter_character_list)
2025-05-08 19:49:50 +05:30
self.external_links_checkbox.toggled.connect(self.update_external_links_setting)
self.thread_count_input.textChanged.connect(self.update_multithreading_label)
self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders)
if self.reset_button:
self.reset_button.clicked.connect(self.reset_application_state)
# Connect log verbosity button if it exists
if self.log_verbosity_button:
self.log_verbosity_button.clicked.connect(self.toggle_log_verbosity)
if self.manga_mode_checkbox:
self.manga_mode_checkbox.toggled.connect(self.update_ui_for_manga_mode)
self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False))
# --- load_known_names_from_util, save_known_names, closeEvent ---
# These methods remain unchanged from your original file
2025-05-07 07:20:40 +05:30
def load_known_names_from_util(self):
2025-05-08 19:49:50 +05:30
global KNOWN_NAMES
2025-05-05 19:35:24 +05:30
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
2025-05-06 22:08:27 +05:30
raw_names = [line.strip() for line in f]
2025-05-08 19:49:50 +05:30
# Filter out empty strings before setting KNOWN_NAMES
KNOWN_NAMES[:] = sorted(list(set(filter(None, raw_names))))
log_msg = f" Loaded {len(KNOWN_NAMES)} known names from {self.config_file}"
2025-05-05 19:35:24 +05:30
except Exception as e:
2025-05-06 22:08:27 +05:30
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}")
2025-05-08 19:49:50 +05:30
KNOWN_NAMES[:] = []
2025-05-05 19:35:24 +05:30
else:
2025-05-06 22:08:27 +05:30
log_msg = f" Config file '{self.config_file}' not found. Starting empty."
2025-05-08 19:49:50 +05:30
KNOWN_NAMES[:] = []
self.log_signal.emit(log_msg)
if hasattr(self, 'character_list'): # Ensure character_list widget exists
2025-05-07 07:20:40 +05:30
self.character_list.clear()
2025-05-08 19:49:50 +05:30
self.character_list.addItems(KNOWN_NAMES)
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
def save_known_names(self):
2025-05-08 19:49:50 +05:30
global KNOWN_NAMES
2025-05-05 19:35:24 +05:30
try:
2025-05-08 19:49:50 +05:30
# Ensure KNOWN_NAMES contains unique, non-empty, sorted strings
unique_sorted_names = sorted(list(set(filter(None, KNOWN_NAMES))))
KNOWN_NAMES[:] = unique_sorted_names # Update global list in place
2025-05-05 19:35:24 +05:30
with open(self.config_file, 'w', encoding='utf-8') as f:
2025-05-06 22:08:27 +05:30
for name in unique_sorted_names:
2025-05-05 19:35:24 +05:30
f.write(name + '\n')
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"💾 Saved {len(unique_sorted_names)} known names to {self.config_file}")
2025-05-05 19:35:24 +05:30
except Exception as e:
2025-05-06 22:08:27 +05:30
log_msg = f"❌ Error saving config '{self.config_file}': {e}"
2025-05-08 19:49:50 +05:30
self.log_signal.emit(log_msg)
2025-05-06 22:08:27 +05:30
QMessageBox.warning(self, "Config Save Error", f"Could not save list to {self.config_file}:\n{e}")
2025-05-07 07:20:40 +05:30
2025-05-05 19:35:24 +05:30
def closeEvent(self, event):
2025-05-06 22:49:19 +05:30
self.save_known_names()
2025-05-06 22:08:27 +05:30
should_exit = True
2025-05-08 19:49:50 +05:30
is_downloading = (self.download_thread and self.download_thread.isRunning()) or \
(self.thread_pool is not None and any(not f.done() for f in self.active_futures if f is not None))
2025-05-06 22:08:27 +05:30
if is_downloading:
2025-05-05 19:35:24 +05:30
reply = QMessageBox.question(self, "Confirm Exit",
2025-05-06 22:08:27 +05:30
"Download in progress. Are you sure you want to exit and cancel?",
2025-05-05 19:35:24 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.Yes:
2025-05-06 22:08:27 +05:30
self.log_signal.emit("⚠️ Cancelling active download due to application exit...")
2025-05-08 19:49:50 +05:30
self.cancel_download() # Signal cancellation
# --- MODIFICATION START: Wait for threads to finish ---
self.log_signal.emit(" Waiting briefly for threads to acknowledge cancellation...")
# Wait for the single thread if it exists
if self.download_thread and self.download_thread.isRunning():
self.download_thread.wait(3000) # Wait up to 3 seconds
if self.download_thread.isRunning():
self.log_signal.emit(" ⚠️ Single download thread did not terminate gracefully.")
# Wait for the thread pool if it exists
if self.thread_pool:
# Shutdown was already initiated by cancel_download, just wait here
# Use wait=True here for cleaner exit
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.log_signal.emit(" Thread pool shutdown complete.")
self.thread_pool = None # Clear reference
# --- MODIFICATION END ---
2025-05-05 19:35:24 +05:30
else:
2025-05-06 22:08:27 +05:30
should_exit = False
self.log_signal.emit(" Application exit cancelled.")
2025-05-06 22:49:19 +05:30
event.ignore()
2025-05-06 22:08:27 +05:30
return
if should_exit:
2025-05-06 22:49:19 +05:30
self.log_signal.emit(" Application closing.")
2025-05-08 19:49:50 +05:30
# Ensure thread pool is None if already shut down above
if self.thread_pool:
self.log_signal.emit(" Final thread pool check: Shutting down...")
self.cancellation_event.set()
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.thread_pool = None
2025-05-06 22:08:27 +05:30
self.log_signal.emit("👋 Exiting application.")
2025-05-06 22:49:19 +05:30
event.accept()
2025-05-07 07:20:40 +05:30
2025-05-05 19:35:24 +05:30
def init_ui(self):
2025-05-08 19:49:50 +05:30
# --- MODIFIED: Use QSplitter for main layout ---
self.main_splitter = QSplitter(Qt.Horizontal)
# Create container widgets for left and right panels
left_panel_widget = QWidget()
right_panel_widget = QWidget()
# Setup layouts for the panels
left_layout = QVBoxLayout(left_panel_widget) # Apply layout to widget
right_layout = QVBoxLayout(right_panel_widget) # Apply layout to widget
left_layout.setContentsMargins(10, 10, 10, 10) # Add some margins
right_layout.setContentsMargins(10, 10, 10, 10)
# --- Populate Left Panel (Controls) ---
# (All the QLineEdit, QCheckBox, QPushButton, etc. setup code goes here, adding to left_layout)
# URL and Page Range Input
url_page_layout = QHBoxLayout()
url_page_layout.setContentsMargins(0,0,0,0)
url_page_layout.addWidget(QLabel("🔗 Kemono Creator/Post URL:"))
2025-05-05 19:35:24 +05:30
self.link_input = QLineEdit()
2025-05-06 22:08:27 +05:30
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)
2025-05-08 19:49:50 +05:30
# self.link_input.setFixedWidth(int(self.width() * 0.45)) # Remove fixed width for splitter
url_page_layout.addWidget(self.link_input, 1) # Give it stretch factor
self.page_range_label = QLabel("Page Range:")
self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;")
self.start_page_input = QLineEdit()
self.start_page_input.setPlaceholderText("Start")
self.start_page_input.setFixedWidth(50)
self.start_page_input.setValidator(QIntValidator(1, 99999)) # Min 1
self.to_label = QLabel("to")
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)) # Min 1
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)
# url_page_layout.addStretch(1) # No need for stretch with splitter
left_layout.addLayout(url_page_layout)
# Download Directory Input
2025-05-06 22:08:27 +05:30
left_layout.addWidget(QLabel("📁 Download Location:"))
2025-05-05 19:35:24 +05:30
self.dir_input = QLineEdit()
2025-05-06 22:08:27 +05:30
self.dir_input.setPlaceholderText("Select folder where downloads will be saved")
self.dir_button = QPushButton("Browse...")
2025-05-05 19:35:24 +05:30
self.dir_button.clicked.connect(self.browse_directory)
dir_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
dir_layout.addWidget(self.dir_input, 1) # Input takes more space
2025-05-05 19:35:24 +05:30
dir_layout.addWidget(self.dir_button)
left_layout.addLayout(dir_layout)
2025-05-08 19:49:50 +05:30
# Custom Folder Name (for single post)
self.custom_folder_widget = QWidget() # Use a widget to hide/show group
2025-05-06 22:08:27 +05:30
custom_folder_layout = QVBoxLayout(self.custom_folder_widget)
2025-05-08 19:49:50 +05:30
custom_folder_layout.setContentsMargins(0, 5, 0, 0) # No top margin if hidden
2025-05-06 22:08:27 +05:30
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_layout.addWidget(self.custom_folder_label)
custom_folder_layout.addWidget(self.custom_folder_input)
2025-05-08 19:49:50 +05:30
self.custom_folder_widget.setVisible(False) # Initially hidden
2025-05-06 22:08:27 +05:30
left_layout.addWidget(self.custom_folder_widget)
2025-05-08 19:49:50 +05:30
# Character Filter Input
2025-05-06 22:08:27 +05:30
self.character_filter_widget = QWidget()
character_filter_layout = QVBoxLayout(self.character_filter_widget)
2025-05-08 19:49:50 +05:30
character_filter_layout.setContentsMargins(0,5,0,0)
self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):")
2025-05-05 19:35:24 +05:30
self.character_input = QLineEdit()
2025-05-08 19:49:50 +05:30
self.character_input.setPlaceholderText("e.g., yor, makima, anya forger")
2025-05-06 22:08:27 +05:30
character_filter_layout.addWidget(self.character_label)
character_filter_layout.addWidget(self.character_input)
2025-05-08 19:49:50 +05:30
self.character_filter_widget.setVisible(True) # Visible by default
2025-05-06 22:08:27 +05:30
left_layout.addWidget(self.character_filter_widget)
2025-05-08 19:49:50 +05:30
# Skip Words Input
2025-05-06 22:08:27 +05:30
left_layout.addWidget(QLabel("🚫 Skip Posts/Files with Words (comma-separated):"))
self.skip_words_input = QLineEdit()
self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
left_layout.addWidget(self.skip_words_input)
2025-05-08 19:49:50 +05:30
# File Type Filter Radio Buttons
file_filter_layout = QVBoxLayout() # Group label and radio buttons
file_filter_layout.setContentsMargins(0,0,0,0) # Compact
file_filter_layout.addWidget(QLabel("Filter Files:"))
radio_button_layout = QHBoxLayout()
radio_button_layout.setSpacing(10)
self.radio_group = QButtonGroup(self) # Ensures one selection
2025-05-06 22:08:27 +05:30
self.radio_all = QRadioButton("All")
self.radio_images = QRadioButton("Images/GIFs")
self.radio_videos = QRadioButton("Videos")
2025-05-05 19:35:24 +05:30
self.radio_all.setChecked(True)
self.radio_group.addButton(self.radio_all)
self.radio_group.addButton(self.radio_images)
self.radio_group.addButton(self.radio_videos)
2025-05-08 19:49:50 +05:30
radio_button_layout.addWidget(self.radio_all)
radio_button_layout.addWidget(self.radio_images)
radio_button_layout.addWidget(self.radio_videos)
radio_button_layout.addStretch(1) # Pushes buttons to left
file_filter_layout.addLayout(radio_button_layout)
left_layout.addLayout(file_filter_layout)
# Checkboxes Group
checkboxes_group_layout = QVBoxLayout()
checkboxes_group_layout.setSpacing(10) # Spacing between rows of checkboxes
row1_layout = QHBoxLayout() # First row of checkboxes
row1_layout.setSpacing(10)
2025-05-06 22:08:27 +05:30
self.skip_zip_checkbox = QCheckBox("Skip .zip")
2025-05-05 19:35:24 +05:30
self.skip_zip_checkbox.setChecked(True)
2025-05-08 19:49:50 +05:30
row1_layout.addWidget(self.skip_zip_checkbox)
2025-05-06 22:08:27 +05:30
self.skip_rar_checkbox = QCheckBox("Skip .rar")
2025-05-05 19:35:24 +05:30
self.skip_rar_checkbox.setChecked(True)
2025-05-08 19:49:50 +05:30
row1_layout.addWidget(self.skip_rar_checkbox)
self.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
self.download_thumbnails_checkbox.setChecked(False)
self.download_thumbnails_checkbox.setToolTip("Thumbnail download functionality is currently limited without the API.")
row1_layout.addWidget(self.download_thumbnails_checkbox)
2025-05-06 22:08:27 +05:30
self.compress_images_checkbox = QCheckBox("Compress Large Images (to WebP)")
self.compress_images_checkbox.setChecked(False)
self.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).")
2025-05-08 19:49:50 +05:30
row1_layout.addWidget(self.compress_images_checkbox)
row1_layout.addStretch(1) # Pushes checkboxes to left
checkboxes_group_layout.addLayout(row1_layout)
# Advanced Settings Section
advanced_settings_label = QLabel("⚙️ Advanced Settings:")
checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout() # Subfolders options
advanced_row1_layout.setSpacing(10)
self.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title")
self.use_subfolders_checkbox.setChecked(True)
self.use_subfolders_checkbox.toggled.connect(self.update_ui_for_subfolders)
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)
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) # Also update UI
advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox)
advanced_row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row1_layout)
advanced_row2_layout = QHBoxLayout() # Multithreading, External Links, Manga Mode
advanced_row2_layout.setSpacing(10)
multithreading_layout = QHBoxLayout() # Group multithreading checkbox and input
multithreading_layout.setContentsMargins(0,0,0,0)
self.use_multithreading_checkbox = QCheckBox("Use Multithreading")
2025-05-06 22:49:19 +05:30
self.use_multithreading_checkbox.setChecked(True)
2025-05-06 22:08:27 +05:30
self.use_multithreading_checkbox.setToolTip("Speeds up downloads for full creator pages.\nSingle post URLs always use one thread.")
2025-05-08 19:49:50 +05:30
multithreading_layout.addWidget(self.use_multithreading_checkbox)
self.thread_count_label = QLabel("Threads:")
multithreading_layout.addWidget(self.thread_count_label)
self.thread_count_input = QLineEdit()
self.thread_count_input.setFixedWidth(40)
self.thread_count_input.setText("4")
self.thread_count_input.setToolTip("Number of threads (recommended: 4-10, max: 200).")
self.thread_count_input.setValidator(QIntValidator(1,200)) # Min 1, Max 200
multithreading_layout.addWidget(self.thread_count_input)
advanced_row2_layout.addLayout(multithreading_layout)
self.external_links_checkbox = QCheckBox("Show External Links in Log")
self.external_links_checkbox.setChecked(False)
advanced_row2_layout.addWidget(self.external_links_checkbox)
self.manga_mode_checkbox = QCheckBox("Manga Mode")
self.manga_mode_checkbox.setToolTip("Process newest posts first, rename files based on post title (for creator feeds only).")
self.manga_mode_checkbox.setChecked(False)
advanced_row2_layout.addWidget(self.manga_mode_checkbox)
advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout)
left_layout.addLayout(checkboxes_group_layout)
# Download and Cancel Buttons
2025-05-05 19:35:24 +05:30
btn_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
btn_layout.setSpacing(10)
2025-05-05 19:35:24 +05:30
self.download_btn = QPushButton("⬇️ Start Download")
2025-05-08 19:49:50 +05:30
self.download_btn.setStyleSheet("padding: 8px 15px; font-weight: bold;") # Make it prominent
2025-05-05 19:35:24 +05:30
self.download_btn.clicked.connect(self.start_download)
2025-05-06 22:08:27 +05:30
self.cancel_btn = QPushButton("❌ Cancel")
2025-05-08 19:49:50 +05:30
self.cancel_btn.setEnabled(False) # Initially disabled
2025-05-06 22:08:27 +05:30
self.cancel_btn.clicked.connect(self.cancel_download)
2025-05-05 19:35:24 +05:30
btn_layout.addWidget(self.download_btn)
btn_layout.addWidget(self.cancel_btn)
left_layout.addLayout(btn_layout)
2025-05-08 19:49:50 +05:30
left_layout.addSpacing(10) # Some space before known characters list
# Known Characters/Shows List Management
2025-05-06 22:08:27 +05:30
known_chars_label_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
known_chars_label_layout.setSpacing(10)
2025-05-06 22:08:27 +05:30
self.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):")
2025-05-06 22:49:19 +05:30
self.character_search_input = QLineEdit()
self.character_search_input.setPlaceholderText("Search characters...")
2025-05-08 19:49:50 +05:30
known_chars_label_layout.addWidget(self.known_chars_label, 1) # Label takes more space
2025-05-06 22:49:19 +05:30
known_chars_label_layout.addWidget(self.character_search_input)
left_layout.addLayout(known_chars_label_layout)
2025-05-08 19:49:50 +05:30
2025-05-05 19:35:24 +05:30
self.character_list = QListWidget()
2025-05-08 19:49:50 +05:30
self.character_list.setSelectionMode(QListWidget.ExtendedSelection) # Allow multi-select for delete
left_layout.addWidget(self.character_list, 1) # Takes remaining vertical space
char_manage_layout = QHBoxLayout() # Add/Delete character buttons
char_manage_layout.setSpacing(10)
2025-05-05 19:35:24 +05:30
self.new_char_input = QLineEdit()
2025-05-06 22:08:27 +05:30
self.new_char_input.setPlaceholderText("Add new show/character name")
2025-05-05 19:35:24 +05:30
self.add_char_button = QPushButton(" Add")
self.delete_char_button = QPushButton("🗑️ Delete Selected")
self.add_char_button.clicked.connect(self.add_new_character)
2025-05-08 19:49:50 +05:30
self.new_char_input.returnPressed.connect(self.add_char_button.click) # Add on Enter
2025-05-05 19:35:24 +05:30
self.delete_char_button.clicked.connect(self.delete_selected_character)
2025-05-08 19:49:50 +05:30
char_manage_layout.addWidget(self.new_char_input, 2) # Input field wider
2025-05-06 22:08:27 +05:30
char_manage_layout.addWidget(self.add_char_button, 1)
char_manage_layout.addWidget(self.delete_char_button, 1)
left_layout.addLayout(char_manage_layout)
2025-05-08 19:49:50 +05:30
left_layout.addStretch(0) # Prevent vertical stretching of controls
# --- Populate Right Panel (Logs) ---
log_title_layout = QHBoxLayout()
log_title_layout.addWidget(QLabel("📜 Progress Log:"))
log_title_layout.addStretch(1)
# --- ADDED: Log Verbosity Button ---
self.log_verbosity_button = QPushButton("Show Basic Log")
self.log_verbosity_button.setToolTip("Toggle between full and basic log details.")
self.log_verbosity_button.setFixedWidth(110) # Adjust width as needed
self.log_verbosity_button.setStyleSheet("padding: 4px 8px;")
log_title_layout.addWidget(self.log_verbosity_button)
# --- END ADDED ---
self.reset_button = QPushButton("🔄 Reset")
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;") # Smaller padding
log_title_layout.addWidget(self.reset_button)
right_layout.addLayout(log_title_layout)
self.log_splitter = QSplitter(Qt.Vertical) # Keep the vertical splitter for logs
self.main_log_output = QTextEdit()
self.main_log_output.setReadOnly(True)
# self.main_log_output.setMinimumWidth(450) # Remove minimum width
self.main_log_output.setLineWrapMode(QTextEdit.NoWrap) # Disable line wrapping
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()
self.external_log_output.setReadOnly(True)
# self.external_log_output.setMinimumWidth(450) # Remove minimum width
self.external_log_output.setLineWrapMode(QTextEdit.NoWrap) # Disable line wrapping
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
self.log_splitter.addWidget(self.main_log_output)
self.log_splitter.addWidget(self.external_log_output)
self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space initially
right_layout.addWidget(self.log_splitter, 1) # Log splitter takes available vertical space
2025-05-06 22:08:27 +05:30
self.progress_label = QLabel("Progress: Idle")
self.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;")
right_layout.addWidget(self.progress_label)
2025-05-08 19:49:50 +05:30
self.file_progress_label = QLabel("") # For individual file progress
self.file_progress_label.setWordWrap(True) # Enable word wrapping for the status label
self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(self.file_progress_label)
# --- Add panels to the main horizontal splitter ---
self.main_splitter.addWidget(left_panel_widget)
self.main_splitter.addWidget(right_panel_widget)
# --- Set initial sizes for the splitter ---
# Calculate initial sizes (e.g., left 35%, right 65%)
initial_width = self.width() # Use the initial window width
left_width = int(initial_width * 0.35)
right_width = initial_width - left_width
self.main_splitter.setSizes([left_width, right_width])
# --- Set the main splitter as the central layout ---
# Need a top-level layout to hold the splitter
top_level_layout = QHBoxLayout(self) # Apply layout directly to the main widget (self)
top_level_layout.setContentsMargins(0,0,0,0) # No margins for the main layout
top_level_layout.addWidget(self.main_splitter)
# self.setLayout(top_level_layout) # Already set above
# --- End Layout Modification ---
# Initial UI state updates
2025-05-06 22:08:27 +05:30
self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked())
self.update_custom_folder_visibility()
2025-05-08 19:49:50 +05:30
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()
if self.manga_mode_checkbox: # Ensure it exists before accessing
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked())
self.link_input.textChanged.connect(self.update_page_range_enabled_state) # Connect after init
self.load_known_names_from_util() # Load names into the list widget
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
def get_dark_theme(self):
return """
2025-05-08 19:49:50 +05:30
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; width: 5px; /* Make handle slightly wider */ }
QSplitter::handle:horizontal { width: 5px; }
QSplitter::handle:vertical { height: 5px; }
""" # Added styling for splitter handle
2025-05-05 19:35:24 +05:30
def browse_directory(self):
2025-05-06 22:08:27 +05:30
current_dir = self.dir_input.text() if os.path.isdir(self.dir_input.text()) else ""
folder = QFileDialog.getExistingDirectory(self, "Select Download Folder", current_dir)
2025-05-05 19:35:24 +05:30
if folder:
self.dir_input.setText(folder)
2025-05-08 19:49:50 +05:30
def handle_main_log(self, message):
# --- ADDED: Log Verbosity Filtering ---
if self.basic_log_mode:
# Define keywords/prefixes for messages to ALWAYS show in basic mode
basic_keywords = [
'🚀 Starting Download', '🏁 Download Finished', '🏁 Download Cancelled',
'', '⚠️', '✅ All posts processed', '✅ Reached end of posts',
'Summary:', 'Progress:', '[Fetcher]', # Show fetcher logs for context
'CRITICAL ERROR', 'IMPORT ERROR'
]
# Check if the message contains any of the basic keywords/prefixes
if not any(keyword in message for keyword in basic_keywords):
return # Skip appending this message in basic mode
# --- END ADDED ---
2025-05-06 22:08:27 +05:30
try:
2025-05-08 19:49:50 +05:30
# Ensure message is a string and replace null characters that can crash QTextEdit
2025-05-06 22:49:19 +05:30
safe_message = str(message).replace('\x00', '[NULL]')
2025-05-08 19:49:50 +05:30
self.main_log_output.append(safe_message)
# Auto-scroll if near the bottom
scrollbar = self.main_log_output.verticalScrollBar()
if scrollbar.value() >= scrollbar.maximum() - 30: # Threshold for auto-scroll
2025-05-06 22:08:27 +05:30
scrollbar.setValue(scrollbar.maximum())
except Exception as e:
2025-05-08 19:49:50 +05:30
# Fallback logging if GUI logging fails
print(f"GUI Main Log Error: {e}\nOriginal Message: {message}")
# --- ADDED: Helper to check download state ---
def _is_download_active(self):
"""Checks if a download thread or pool is currently active."""
single_thread_active = self.download_thread and self.download_thread.isRunning()
# Check if pool exists AND has any futures that are not done
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
# --- END ADDED ---
# --- ADDED: New system for handling external links with sequential CONDIITONAL delay ---
# MODIFIED: Slot now takes link_text as the second argument
def handle_external_link_signal(self, post_title, link_text, link_url, platform):
"""Receives link signals, adds them to a queue, and triggers processing."""
# We still receive post_title for potential future use, but use link_text for display
self.external_link_queue.append((link_text, link_url, platform))
self._try_process_next_external_link()
def _try_process_next_external_link(self):
"""Processes the next link from the queue if not already processing."""
if self._is_processing_external_link_queue or \
not self.external_link_queue or \
not (self.show_external_links and self.external_log_output and self.external_log_output.isVisible()):
return
self._is_processing_external_link_queue = True
# MODIFIED: Get link_text from queue
link_text, link_url, platform = self.external_link_queue.popleft()
self._append_link_to_external_log(link_text, link_url, platform) # Display this link now
# --- MODIFIED: Conditional delay ---
if self._is_download_active():
# Schedule the end of this link's "display period" with delay
delay_ms = random.randint(4000, 8000) # Random delay of 4-8 seconds
QTimer.singleShot(delay_ms, self._finish_current_link_processing)
else:
# No download active, process next link almost immediately
QTimer.singleShot(0, self._finish_current_link_processing)
# --- END MODIFIED ---
def _finish_current_link_processing(self):
"""Called after a delay (or immediately if download finished); allows the next link in the queue to be processed."""
self._is_processing_external_link_queue = False
self._try_process_next_external_link() # Attempt to process the next link
# MODIFIED: Method now takes link_text instead of title for display
def _append_link_to_external_log(self, link_text, link_url, platform):
"""Appends a single formatted link to the external_log_output widget."""
if not (self.show_external_links and self.external_log_output and self.external_log_output.isVisible()):
return
# Use link_text for display, truncate if necessary
max_link_text_len = 35 # Adjust as needed
display_text = link_text[:max_link_text_len].strip() + "..." if len(link_text) > max_link_text_len else link_text
# Format the string as requested: text - url - platform
formatted_link_text = f"{display_text} - {link_url} - {platform}"
separator = "-" * 45 # Adjust length as needed
try:
self.external_log_output.append(separator)
self.external_log_output.append(formatted_link_text)
self.external_log_output.append("") # Add a blank line for spacing
# Auto-scroll
scrollbar = self.external_log_output.verticalScrollBar()
if scrollbar.value() >= scrollbar.maximum() - 50: # Adjust threshold if needed
scrollbar.setValue(scrollbar.maximum())
except Exception as e:
self.log_signal.emit(f"GUI External Log Append Error: {e}\nOriginal Message: {formatted_link_text}")
print(f"GUI External Log Error (Append): {e}\nOriginal Message: {formatted_link_text}")
# --- END ADDED ---
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
2025-05-08 19:49:50 +05:30
def update_file_progress_display(self, filename, downloaded_bytes, total_bytes):
if not filename and total_bytes == 0 and downloaded_bytes == 0: # Clear signal
self.file_progress_label.setText("")
return
# MODIFIED: Truncate filename more aggressively (e.g., max 25 chars)
max_filename_len = 25
display_filename = filename[:max_filename_len-3].strip() + "..." if len(filename) > max_filename_len else filename
if total_bytes > 0:
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)"
# Check if the resulting text might still be too long (heuristic)
# This is a basic check, might need refinement based on typical log width
if len(progress_text) > 75: # Example threshold, adjust as needed
# If still too long, truncate the display_filename even more
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)
def update_external_links_setting(self, checked):
self.show_external_links = checked
if checked:
self.external_log_output.show()
# Adjust splitter, give both logs some space
# Use the VERTICAL splitter for logs here
self.log_splitter.setSizes([self.height() // 2, self.height() // 2])
self.main_log_output.setMinimumHeight(50) # Ensure it doesn't disappear
self.external_log_output.setMinimumHeight(50)
self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Enabled\n" + "="*40)
self.external_log_output.clear() # Clear previous content
self.external_log_output.append("🔗 External Links Found:") # Header
# --- ADDED: Try processing queue if log becomes visible ---
self._try_process_next_external_link()
# --- END ADDED ---
else:
self.external_log_output.hide()
# Use the VERTICAL splitter for logs here
self.log_splitter.setSizes([self.height(), 0]) # Main log takes all space
self.main_log_output.setMinimumHeight(0) # Reset min height
self.external_log_output.setMinimumHeight(0)
self.external_log_output.clear() # Clear content when hidden
self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Disabled\n" + "="*40)
# Optional: Clear queue when log is hidden?
# self.external_link_queue.clear()
# self._is_processing_external_link_queue = False
2025-05-05 19:35:24 +05:30
def get_filter_mode(self):
2025-05-08 19:49:50 +05:30
if self.radio_images.isChecked(): return 'image'
if self.radio_videos.isChecked(): return 'video'
return 'all' # Default
2025-05-05 19:35:24 +05:30
def add_new_character(self):
2025-05-08 19:49:50 +05:30
global KNOWN_NAMES, clean_folder_name # Ensure clean_folder_name is accessible
2025-05-06 22:08:27 +05:30
name_to_add = self.new_char_input.text().strip()
if not name_to_add:
QMessageBox.warning(self, "Input Error", "Name cannot be empty.")
2025-05-08 19:49:50 +05:30
return False # Indicate failure
2025-05-06 22:08:27 +05:30
name_lower = name_to_add.lower()
2025-05-08 19:49:50 +05:30
# 1. Exact Duplicate Check (case-insensitive)
is_exact_duplicate = any(existing.lower() == name_lower for existing in KNOWN_NAMES)
if is_exact_duplicate:
QMessageBox.warning(self, "Duplicate Name", f"The name '{name_to_add}' (case-insensitive) already exists.")
return False
# 2. Similarity Check (substring, case-insensitive)
similar_names_details = [] # Store tuples of (new_name, existing_name)
for existing_name in KNOWN_NAMES:
existing_name_lower = existing_name.lower()
# Avoid self-comparison if somehow name_lower was already in a different case
if name_lower != existing_name_lower:
if name_lower in existing_name_lower or existing_name_lower in name_lower:
similar_names_details.append((name_to_add, existing_name))
if similar_names_details:
first_similar_new, first_similar_existing = similar_names_details[0]
# Determine shorter and longer for the example message
shorter_name_for_msg, longer_name_for_msg = sorted(
[first_similar_new, first_similar_existing], key=len
)
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"For example, if a post title primarily matches the shorter name ('{shorter_name_for_msg}'), "
f"files might be saved under a folder for '{clean_folder_name(shorter_name_for_msg)}', "
f"even if the longer name ('{longer_name_for_msg}') was also relevant or intended for a more specific folder.\n"
"This could lead to files being grouped into less specific or overly broad folders than desired.\n\n"
"Do you want to change the name you are adding, or proceed anyway?"
)
change_button = msg_box.addButton("Change Name", QMessageBox.RejectRole)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
msg_box.setDefaultButton(proceed_button) # Default to proceed
msg_box.setEscapeButton(change_button) # Escape cancels/rejects
msg_box.exec_()
if msg_box.clickedButton() == change_button:
self.log_signal.emit(f" User chose to change the name '{first_similar_new}' due to similarity with '{first_similar_existing}'.")
return False # Don't add, user will change input and click "Add" again
# If proceed_button is clicked (or dialog is closed and proceed is default)
self.log_signal.emit(f"⚠️ User chose to proceed with adding '{first_similar_new}' despite similarity with '{first_similar_existing}'.")
# Fall through to add the name
# If no exact duplicate, and (no similar names OR user chose to proceed with similar name)
KNOWN_NAMES.append(name_to_add)
KNOWN_NAMES.sort(key=str.lower) # Keep the list sorted (case-insensitive for sorting)
self.character_list.clear()
self.character_list.addItems(KNOWN_NAMES)
self.filter_character_list(self.character_search_input.text()) # Re-apply filter
self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list.")
self.new_char_input.clear()
self.save_known_names() # Save to file
return True # Indicate success
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
def delete_selected_character(self):
2025-05-08 19:49:50 +05:30
global KNOWN_NAMES
2025-05-05 19:35:24 +05:30
selected_items = self.character_list.selectedItems()
if not selected_items:
2025-05-06 22:08:27 +05:30
QMessageBox.warning(self, "Selection Error", "Please select one or more names to delete.")
2025-05-05 19:35:24 +05:30
return
2025-05-06 22:08:27 +05:30
names_to_remove = {item.text() for item in selected_items}
2025-05-05 19:35:24 +05:30
confirm = QMessageBox.question(self, "Confirm Deletion",
2025-05-08 19:49:50 +05:30
f"Are you sure you want to delete {len(names_to_remove)} name(s)?",
2025-05-05 19:35:24 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if confirm == QMessageBox.Yes:
2025-05-08 19:49:50 +05:30
original_count = len(KNOWN_NAMES)
# Filter out names to remove
KNOWN_NAMES = [n for n in KNOWN_NAMES if n not in names_to_remove]
removed_count = original_count - len(KNOWN_NAMES)
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
if removed_count > 0:
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"🗑️ Removed {removed_count} name(s).")
self.character_list.clear() # Update UI
self.character_list.addItems(KNOWN_NAMES)
self.filter_character_list(self.character_search_input.text()) # Re-apply filter
self.save_known_names() # Save changes
2025-05-06 22:08:27 +05:30
else:
2025-05-08 19:49:50 +05:30
self.log_signal.emit(" No names were removed (they might not have been in the list or already deleted).")
2025-05-06 22:08:27 +05:30
def update_custom_folder_visibility(self, url_text=None):
2025-05-08 19:49:50 +05:30
if url_text is None: url_text = self.link_input.text() # Get current text if not passed
_, _, post_id = extract_post_info(url_text.strip())
# Show if it's a post URL AND subfolders are generally enabled
2025-05-06 22:08:27 +05:30
should_show = bool(post_id) and self.use_subfolders_checkbox.isChecked()
self.custom_folder_widget.setVisible(should_show)
2025-05-08 19:49:50 +05:30
if not should_show: self.custom_folder_input.clear() # Clear if hidden
2025-05-06 22:08:27 +05:30
def update_ui_for_subfolders(self, checked):
2025-05-08 19:49:50 +05:30
# Character filter input visibility depends on subfolder usage
self.character_filter_widget.setVisible(checked)
if not checked: self.character_input.clear() # Clear filter if hiding
self.update_custom_folder_visibility() # Custom folder also depends on this
# "Subfolder per Post" is only enabled if "Separate Folders" is also checked
self.use_subfolder_per_post_checkbox.setEnabled(checked)
if not checked: self.use_subfolder_per_post_checkbox.setChecked(False) # Uncheck if parent is disabled
def update_page_range_enabled_state(self):
url_text = self.link_input.text().strip()
service, user_id, post_id = extract_post_info(url_text)
# Page range is for creator feeds (no post_id)
is_creator_feed = service is not None and user_id is not None and post_id is None
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 NOT active
enable_page_range = is_creator_feed and not manga_mode_active
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 not enable_page_range: # Clear inputs if disabled
self.start_page_input.clear()
self.end_page_input.clear()
def update_ui_for_manga_mode(self, checked):
url_text = self.link_input.text().strip()
_, _, post_id = extract_post_info(url_text)
is_creator_feed = not post_id if url_text else False # Manga mode only for creator feeds
if self.manga_mode_checkbox: # Ensure checkbox exists
self.manga_mode_checkbox.setEnabled(is_creator_feed) # Only enable for creator feeds
if not is_creator_feed and self.manga_mode_checkbox.isChecked():
self.manga_mode_checkbox.setChecked(False) # Uncheck if URL changes to non-creator feed
# If manga mode is active (checked and enabled), disable page range
if is_creator_feed and self.manga_mode_checkbox and self.manga_mode_checkbox.isChecked():
self.page_range_label.setEnabled(False)
self.start_page_input.setEnabled(False); self.start_page_input.clear()
self.to_label.setEnabled(False)
self.end_page_input.setEnabled(False); self.end_page_input.clear()
else: # Otherwise, let update_page_range_enabled_state handle it
self.update_page_range_enabled_state()
2025-05-07 07:20:40 +05:30
2025-05-06 22:08:27 +05:30
def filter_character_list(self, search_text):
2025-05-08 19:49:50 +05:30
search_text_lower = search_text.lower()
2025-05-06 22:08:27 +05:30
for i in range(self.character_list.count()):
item = self.character_list.item(i)
2025-05-08 19:49:50 +05:30
item.setHidden(search_text_lower not in item.text().lower())
def update_multithreading_label(self, text):
try:
num_threads = int(text)
if num_threads > 0 :
self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads} Threads)")
else: # Should be caught by validator, but defensive
self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)")
except ValueError: # If text is not a valid integer
self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)")
2025-05-06 22:08:27 +05:30
def update_progress_display(self, total_posts, processed_posts):
if total_posts > 0:
2025-05-08 19:49:50 +05:30
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_posts is unknown (e.g., single post)
2025-05-06 22:08:27 +05:30
self.progress_label.setText(f"Progress: Processing post {processed_posts}...")
2025-05-08 19:49:50 +05:30
else: # Initial state or no posts
2025-05-06 22:08:27 +05:30
self.progress_label.setText("Progress: Starting...")
2025-05-08 19:49:50 +05:30
if total_posts > 0 or processed_posts > 0 : self.file_progress_label.setText("") # Clear file progress
2025-05-05 19:35:24 +05:30
def start_download(self):
2025-05-08 19:49:50 +05:30
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name
if (self.download_thread and self.download_thread.isRunning()) or self.thread_pool:
2025-05-06 22:08:27 +05:30
QMessageBox.warning(self, "Busy", "A download is already running.")
2025-05-05 19:35:24 +05:30
return
2025-05-08 19:49:50 +05:30
2025-05-05 19:35:24 +05:30
api_url = self.link_input.text().strip()
output_dir = self.dir_input.text().strip()
filter_mode = self.get_filter_mode()
skip_zip = self.skip_zip_checkbox.isChecked()
skip_rar = self.skip_rar_checkbox.isChecked()
use_subfolders = self.use_subfolders_checkbox.isChecked()
2025-05-08 19:49:50 +05:30
use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked() and use_subfolders
2025-05-06 22:08:27 +05:30
compress_images = self.compress_images_checkbox.isChecked()
download_thumbnails = self.download_thumbnails_checkbox.isChecked()
use_multithreading = self.use_multithreading_checkbox.isChecked()
raw_skip_words = self.skip_words_input.text().strip()
2025-05-08 19:49:50 +05:30
skip_words_list = [word.strip().lower() for word in raw_skip_words.split(',') if word.strip()]
manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
if not api_url or not output_dir:
QMessageBox.critical(self, "Input Error", "URL and Download Directory are required."); return
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id: # Basic validation of extracted info
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format."); return
2025-05-06 22:08:27 +05:30
if not os.path.isdir(output_dir):
2025-05-08 19:49:50 +05:30
reply = QMessageBox.question(self, "Create Directory?",
f"The directory '{output_dir}' does not exist.\nCreate it now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes:
try:
os.makedirs(output_dir, exist_ok=True) # exist_ok=True is safer
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
if compress_images and Image is None: # Check for Pillow if compression is enabled
QMessageBox.warning(self, "Missing Dependency", "Pillow library (for image compression) not found. Compression will be disabled.")
compress_images = False # Disable it for this run
self.compress_images_checkbox.setChecked(False) # Update UI
manga_mode = manga_mode_is_checked and not post_id_from_url # Manga mode only for creator feeds
num_threads_str = self.thread_count_input.text().strip()
MAX_ALLOWED_THREADS = 200
MODERATE_THREAD_WARNING_THRESHOLD = 50
try:
num_threads = int(num_threads_str)
if not (1 <= num_threads <= MAX_ALLOWED_THREADS): # Validate thread count
QMessageBox.critical(self, "Thread Count Error", f"Number of threads must be between 1 and {MAX_ALLOWED_THREADS}.")
self.thread_count_input.setText(str(min(max(1, num_threads), MAX_ALLOWED_THREADS))) # Correct to valid range
2025-05-06 22:08:27 +05:30
return
2025-05-08 19:49:50 +05:30
if num_threads > MODERATE_THREAD_WARNING_THRESHOLD: # Warn for very high thread counts
QMessageBox.information(self, "High Thread Count Note",
f"Using {num_threads} threads (above {MODERATE_THREAD_WARNING_THRESHOLD}) may increase resource usage and risk rate-limiting from the site.\n\nProceeding with caution.")
self.log_signal.emit(f" Using high thread count: {num_threads}.")
except ValueError:
QMessageBox.critical(self, "Thread Count Error", "Invalid number of threads. Please enter a numeric value."); return
start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip()
start_page, end_page = None, None
is_creator_feed = bool(not post_id_from_url)
if is_creator_feed and not manga_mode: # Page range only for non-manga creator feeds
try:
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: # Manga mode processes all pages (reversed in downloader_utils)
start_page, end_page = None, None
# --- ADDED: Clear link queue before starting new download ---
self.external_link_queue.clear()
self._is_processing_external_link_queue = False
# --- END ADDED ---
raw_character_filters_text = self.character_input.text().strip()
parsed_character_list = None
if raw_character_filters_text:
temp_list = [name.strip() for name in raw_character_filters_text.split(',') if name.strip()]
if temp_list: parsed_character_list = temp_list
filter_character_list_to_pass = None # This will be passed to backend
if use_subfolders and parsed_character_list and not post_id_from_url: # Validate filters if used for subfolders
self.log_signal.emit(f" Validating character filters for subfolder naming: {', '.join(parsed_character_list)}")
valid_filters_for_backend = []
user_cancelled_validation = False
for char_name in parsed_character_list:
cleaned_name_test = clean_folder_name(char_name) # Test if name is valid for folder
if not cleaned_name_test:
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
# Prompt to add to known_names if not already there
if char_name.lower() not in {kn.lower() for kn in KNOWN_NAMES}:
reply = QMessageBox.question(self, "Add Filter Name to Known List?",
f"The character filter '{char_name}' is not in your known names list (used for folder suggestions).\nAdd it now?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes)
if reply == QMessageBox.Yes:
self.new_char_input.setText(char_name) # Use existing add mechanism
if self.add_new_character(): # This now handles similarity checks too
self.log_signal.emit(f"✅ Added '{char_name}' to known names via filter prompt.")
valid_filters_for_backend.append(char_name)
else: # add_new_character returned False (e.g., user chose "Change Name" or it failed)
self.log_signal.emit(f"⚠️ Failed to add '{char_name}' via filter prompt (or user opted out). It will still be used for filtering this session if valid.")
# Still add to backend list for current session if it's a valid folder name
if cleaned_name_test: valid_filters_for_backend.append(char_name)
elif reply == QMessageBox.Cancel:
self.log_signal.emit(f"❌ Download cancelled by user during filter validation for '{char_name}'.")
user_cancelled_validation = True; break
else: # User chose No
self.log_signal.emit(f" Proceeding with filter '{char_name}' for matching without adding to known list.")
if cleaned_name_test: valid_filters_for_backend.append(char_name)
else: # Already in known names
if cleaned_name_test: valid_filters_for_backend.append(char_name)
if user_cancelled_validation: return # Stop download if user cancelled
if valid_filters_for_backend:
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:
self.log_signal.emit("⚠️ No valid character filters remaining after validation for subfolder naming.")
elif parsed_character_list : # Filters provided, but not for subfolders (e.g. subfolders disabled)
filter_character_list_to_pass = parsed_character_list
self.log_signal.emit(f" Character filters provided: {', '.join(filter_character_list_to_pass)} (Subfolder creation rules may differ).")
# --- ADDED: Manga Mode Filter Warning ---
if manga_mode and not filter_character_list_to_pass:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Manga Mode Filter Warning")
msg_box.setText(
"Manga Mode is enabled, but the 'Filter by Character(s)' field is empty.\n\n"
"For best results (correct file naming and grouping), please enter the exact Manga/Series title "
"(as used by the creator on the site) into the filter field.\n\n"
"Do you want to proceed without a filter (file names might be generic) or cancel?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole) # YesRole/AcceptRole makes it default
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole) # NoRole/RejectRole for cancel
msg_box.exec_()
if msg_box.clickedButton() == cancel_button:
self.log_signal.emit("❌ Download cancelled by user due to Manga Mode filter warning.")
return # Stop the download process here
else:
self.log_signal.emit("⚠️ Proceeding with Manga Mode without a specific title filter.")
# --- END ADDED ---
custom_folder_name_cleaned = None
2025-05-06 22:08:27 +05:30
if use_subfolders and post_id_from_url and self.custom_folder_widget.isVisible():
raw_custom_name = self.custom_folder_input.text().strip()
if raw_custom_name:
2025-05-08 19:49:50 +05:30
cleaned_custom = clean_folder_name(raw_custom_name)
if cleaned_custom: custom_folder_name_cleaned = cleaned_custom
else: self.log_signal.emit(f"⚠️ Invalid custom folder name ignored: '{raw_custom_name}'")
# Reset UI elements for new download
self.main_log_output.clear()
if self.show_external_links: self.external_log_output.clear(); self.external_log_output.append("🔗 External Links Found:") # Changed title slightly
self.file_progress_label.setText("")
self.cancellation_event.clear() # IMPORTANT: Clear cancellation from previous run
2025-05-06 22:08:27 +05:30
self.active_futures = []
2025-05-08 19:49:50 +05:30
self.total_posts_to_process = self.processed_posts_count = self.download_counter = self.skip_counter = 0
2025-05-06 22:08:27 +05:30
self.progress_label.setText("Progress: Initializing...")
2025-05-08 19:49:50 +05:30
# Log download parameters
log_messages = [
"="*40, f"🚀 Starting Download @ {time.strftime('%Y-%m-%d %H:%M:%S')}",
f" URL: {api_url}", f" Save Location: {output_dir}",
f" Mode: {'Single Post' if post_id_from_url else 'Creator Feed'}",
]
if is_creator_feed:
if manga_mode:
log_messages.append(" Page Range: All (Manga Mode - Oldest Posts Processed First)")
else:
pr_log = "All"
if start_page or end_page: # Construct page range log string
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'}")
log_messages.append(f" Subfolders: {'Enabled' if use_subfolders else 'Disabled'}")
2025-05-06 22:08:27 +05:30
if use_subfolders:
2025-05-08 19:49:50 +05:30
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: {filter_mode}",
f" Skip Archives: {'.zip' if skip_zip else ''}{', ' if skip_zip and skip_rar else ''}{'.rar' if skip_rar else ''}{'None' if not (skip_zip or skip_rar) else ''}",
f" Skip Words (posts/files): {', '.join(skip_words_list) if skip_words_list else 'None'}",
f" Compress Images: {'Enabled' if compress_images else 'Disabled'}",
f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}",
f" Show External Links: {'Enabled' if self.show_external_links else 'Disabled'}"
])
if manga_mode: log_messages.append(f" Manga Mode (File Renaming by Post Title): Enabled")
should_use_multithreading = use_multithreading and not post_id_from_url # Multi-threading for creator feeds
log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading else 'Single-threaded (posts)'}")
if should_use_multithreading: log_messages.append(f" Number of Post Worker Threads: {num_threads}")
log_messages.append("="*40)
for msg in log_messages: self.log_signal.emit(msg)
self.set_ui_enabled(False) # Disable UI during download
unwanted_keywords_for_folders = {'spicy', 'hd', 'nsfw', '4k', 'preview', 'teaser', 'clip'}
# Prepare arguments for worker threads/classes
args_template = {
'api_url_input': api_url,
'download_root': output_dir, # For PostProcessorWorker
'output_dir': output_dir, # For DownloadThread __init__
'known_names': list(KNOWN_NAMES), # Pass a copy
'known_names_copy': list(KNOWN_NAMES), # For DownloadThread __init__
'filter_character_list': filter_character_list_to_pass,
'filter_mode': filter_mode, 'skip_zip': skip_zip, 'skip_rar': skip_rar,
'use_subfolders': use_subfolders, 'use_post_subfolders': use_post_subfolders,
'compress_images': compress_images, 'download_thumbnails': download_thumbnails,
'service': service, 'user_id': user_id,
'downloaded_files': self.downloaded_files, # Shared set
'downloaded_files_lock': self.downloaded_files_lock, # Shared lock
'downloaded_file_hashes': self.downloaded_file_hashes, # Shared set
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock, # Shared lock
'skip_words_list': skip_words_list,
'show_external_links': self.show_external_links,
'start_page': start_page,
'end_page': end_page,
'target_post_id_from_initial_url': post_id_from_url,
'custom_folder_name': custom_folder_name_cleaned,
'manga_mode_active': manga_mode,
'unwanted_keywords': unwanted_keywords_for_folders,
'cancellation_event': self.cancellation_event, # Crucial for stopping threads
'signals': self.worker_signals, # For multi-threaded PostProcessorWorker
}
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
try:
2025-05-06 22:08:27 +05:30
if should_use_multithreading:
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f" Initializing multi-threaded download with {num_threads} post workers...")
self.start_multi_threaded_download(num_post_workers=num_threads, **args_template)
else: # Single post URL or multithreading disabled
2025-05-06 22:08:27 +05:30
self.log_signal.emit(" Initializing single-threaded download...")
2025-05-08 19:49:50 +05:30
# Keys expected by DownloadThread constructor
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', 'show_external_links',
'num_file_threads_for_worker', 'skip_current_file_flag',
'start_page', 'end_page', 'target_post_id_from_initial_url',
'manga_mode_active', 'unwanted_keywords'
]
args_template['num_file_threads_for_worker'] = 1 # Single thread mode, worker uses 1 file thread
args_template['skip_current_file_flag'] = None # No skip flag initially
single_thread_args = {}
for key in dt_expected_keys:
if key in args_template:
single_thread_args[key] = args_template[key]
# Missing optional keys will use defaults in DownloadThread's __init__
self.start_single_threaded_download(**single_thread_args)
2025-05-06 22:08:27 +05:30
except Exception as e:
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"❌ CRITICAL ERROR preparing download: {e}\n{traceback.format_exc()}")
QMessageBox.critical(self, "Start Error", f"Failed to start download:\n{e}")
self.download_finished(0,0,False) # Ensure UI is re-enabled
2025-05-06 22:08:27 +05:30
def start_single_threaded_download(self, **kwargs):
2025-05-08 19:49:50 +05:30
global BackendDownloadThread
2025-05-06 22:08:27 +05:30
try:
2025-05-08 19:49:50 +05:30
self.download_thread = BackendDownloadThread(**kwargs) # Pass all relevant args
# Connect signals from the DownloadThread instance
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'): # Though less used by DownloadThread directly
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.finished_signal) # Connect to app's finished handler
if hasattr(self.download_thread, 'receive_add_character_result'): # For two-way prompt communication
self.character_prompt_response_signal.connect(self.download_thread.receive_add_character_result)
# MODIFIED: Connect external_link_signal to the new handler
if hasattr(self.download_thread, 'external_link_signal'):
self.download_thread.external_link_signal.connect(self.handle_external_link_signal) # Connect to queue handler
if hasattr(self.download_thread, 'file_progress_signal'):
self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
2025-05-06 22:08:27 +05:30
self.download_thread.start()
2025-05-08 19:49:50 +05:30
self.log_signal.emit("✅ Single download thread (for posts) started.")
2025-05-06 22:08:27 +05:30
except Exception as e:
2025-05-08 19:49:50 +05:30
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) # Cleanup
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
def start_multi_threaded_download(self, num_post_workers, **kwargs):
global PostProcessorWorker # Ensure it's the correct worker class
self.thread_pool = ThreadPoolExecutor(max_workers=num_post_workers, thread_name_prefix='PostWorker_')
self.active_futures = [] # Reset list of active futures
2025-05-06 22:08:27 +05:30
self.processed_posts_count = 0
2025-05-08 19:49:50 +05:30
self.total_posts_to_process = 0 # Will be updated by _fetch_and_queue_posts
2025-05-06 22:08:27 +05:30
self.download_counter = 0
self.skip_counter = 0
2025-05-08 19:49:50 +05:30
# Start a separate thread to fetch post data and submit tasks to the pool
# This prevents the GUI from freezing during the initial API calls for post lists
2025-05-06 22:08:27 +05:30
fetcher_thread = threading.Thread(
2025-05-08 19:49:50 +05:30
target=self._fetch_and_queue_posts,
args=(kwargs['api_url_input'], kwargs, num_post_workers),
daemon=True, name="PostFetcher" # Daemon thread will exit when app exits
2025-05-06 22:08:27 +05:30
)
fetcher_thread.start()
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"✅ Post fetcher thread started. {num_post_workers} post worker threads initializing...")
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
def _fetch_and_queue_posts(self, api_url_input_for_fetcher, worker_args_template, num_post_workers):
global PostProcessorWorker, download_from_api # Ensure correct references
all_posts_data = []
fetch_error_occurred = False
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
manga_mode_active_for_fetch = worker_args_template.get('manga_mode_active', False)
signals_for_worker = worker_args_template.get('signals') # This is self.worker_signals
if not signals_for_worker: # Should always be present
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
return
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
try:
self.log_signal.emit(" Fetching post data from API...")
post_generator = download_from_api(
api_url_input_for_fetcher,
logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"), # Prefix fetcher logs
start_page=worker_args_template.get('start_page'),
end_page=worker_args_template.get('end_page'),
manga_mode=manga_mode_active_for_fetch,
cancellation_event=self.cancellation_event # Pass cancellation event
)
2025-05-06 22:08:27 +05:30
for posts_batch in post_generator:
2025-05-08 19:49:50 +05:30
if self.cancellation_event.is_set(): # Check cancellation frequently
fetch_error_occurred = True; self.log_signal.emit(" Post fetching cancelled by user."); break
2025-05-06 22:08:27 +05:30
if isinstance(posts_batch, list):
2025-05-08 19:49:50 +05:30
all_posts_data.extend(posts_batch)
self.total_posts_to_process = len(all_posts_data) # Update total
# Log progress periodically for large feeds
if self.total_posts_to_process > 0 and self.total_posts_to_process % 100 == 0 :
self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts so far...")
else: # Should not happen if download_from_api is correct
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():
self.log_signal.emit(f"✅ Post fetching complete. Total posts to process: {self.total_posts_to_process}")
except TypeError as te: # Catch common error if downloader_utils is outdated
self.log_signal.emit(f"❌ TypeError calling download_from_api: {te}")
self.log_signal.emit(" Check if 'downloader_utils.py' has the correct 'download_from_api' signature (including 'manga_mode' and 'cancellation_event').")
self.log_signal.emit(traceback.format_exc(limit=2))
fetch_error_occurred = True
except RuntimeError as re: # Catch cancellation from fetch_posts_paginated
self.log_signal.emit(f" Post fetching runtime error (likely cancellation): {re}")
fetch_error_occurred = True # Treat as an error for cleanup
2025-05-06 22:08:27 +05:30
except Exception as e:
2025-05-08 19:49:50 +05:30
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:
2025-05-06 22:08:27 +05:30
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set())
2025-05-08 19:49:50 +05:30
if self.thread_pool: # Ensure pool is shutdown if fetch fails or is cancelled
self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None
2025-05-06 22:49:19 +05:30
return
2025-05-06 22:08:27 +05:30
if self.total_posts_to_process == 0:
2025-05-08 19:49:50 +05:30
self.log_signal.emit("😕 No posts found or fetched to process.")
self.finished_signal.emit(0,0,False); return
self.log_signal.emit(f" Submitting {self.total_posts_to_process} post processing tasks to thread pool...")
self.processed_posts_count = 0 # Reset for this run
self.overall_progress_signal.emit(self.total_posts_to_process, 0) # Initial progress update
num_file_dl_threads = 4 # Default for PostProcessorWorker's internal pool
# Define keys PostProcessorWorker expects (ensure this matches its __init__)
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', 'show_external_links',
'extract_links_only', 'num_file_threads', 'skip_current_file_flag',
'manga_mode_active'
]
# Optional keys with defaults in PostProcessorWorker's __init__
ppw_optional_keys_with_defaults = {
'skip_words_list', 'show_external_links', 'extract_links_only',
'num_file_threads', 'skip_current_file_flag', 'manga_mode_active'
2025-05-06 22:08:27 +05:30
}
2025-05-08 19:49:50 +05:30
for post_data_item in all_posts_data:
if self.cancellation_event.is_set(): break # Check before submitting each task
if not isinstance(post_data_item, dict): # Basic sanity check
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/skipped
2025-05-06 22:08:27 +05:30
continue
2025-05-08 19:49:50 +05:30
# Build args for PostProcessorWorker instance
worker_init_args = {}
missing_keys = []
for key in ppw_expected_keys:
if key == 'post_data': worker_init_args[key] = post_data_item
elif key == 'num_file_threads': worker_init_args[key] = num_file_dl_threads
elif key == 'signals': worker_init_args[key] = signals_for_worker # Use the app's worker_signals
elif key in worker_args_template: worker_init_args[key] = worker_args_template[key]
elif key in ppw_optional_keys_with_defaults: pass # Let worker use its default
else: missing_keys.append(key) # Required key is missing
if missing_keys:
self.log_signal.emit(f"❌ CRITICAL ERROR: Missing expected keys for PostProcessorWorker: {', '.join(missing_keys)}")
self.cancellation_event.set() # Stop all processing
2025-05-06 22:08:27 +05:30
break
2025-05-08 19:49:50 +05:30
try:
worker_instance = PostProcessorWorker(**worker_init_args)
if self.thread_pool: # Ensure pool is still active
future = self.thread_pool.submit(worker_instance.process)
future.add_done_callback(self._handle_future_result) # Handle result/exception
self.active_futures.append(future)
else: # Pool might have been shut down due to earlier error/cancellation
self.log_signal.emit("⚠️ Thread pool not available. Cannot submit more tasks.")
break
except TypeError as te: # Error creating worker (e.g. wrong args)
self.log_signal.emit(f"❌ TypeError creating PostProcessorWorker: {te}")
passed_keys_str = ", ".join(sorted(worker_init_args.keys()))
self.log_signal.emit(f" Passed Args: [{passed_keys_str}]")
self.log_signal.emit(traceback.format_exc(limit=5))
self.cancellation_event.set(); break # Stop all
except RuntimeError: # Pool might be shutting down
self.log_signal.emit("⚠️ Runtime error submitting task (pool likely shutting down)."); break
except Exception as e: # Other errors during submission
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: # If cancelled during submission loop
self.finished_signal.emit(self.download_counter, self.skip_counter, True)
if self.thread_pool:
self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None
2025-05-06 22:08:27 +05:30
def _handle_future_result(self, future: Future):
self.processed_posts_count += 1
2025-05-08 19:49:50 +05:30
downloaded_files_from_future = 0
skipped_files_from_future = 0
2025-05-06 22:08:27 +05:30
try:
if future.cancelled():
2025-05-08 19:49:50 +05:30
self.log_signal.emit(" A post processing task was cancelled.")
# If a task was cancelled, it implies we might want to count its potential files as skipped
# This is hard to determine without knowing the post_data it was handling.
# For simplicity, we don't add to skip_counter here unless future.result() would have.
2025-05-06 22:08:27 +05:30
elif future.exception():
2025-05-08 19:49:50 +05:30
worker_exception = future.exception()
self.log_signal.emit(f"❌ Post processing worker error: {worker_exception}")
# Similar to cancelled, hard to know how many files were skipped due to error.
else: # Success
downloaded_files_from_future, skipped_files_from_future = future.result()
# Lock for updating shared counters
with self.downloaded_files_lock: # Using this lock for these counters too
self.download_counter += downloaded_files_from_future
self.skip_counter += skipped_files_from_future
2025-05-05 19:35:24 +05:30
2025-05-06 22:08:27 +05:30
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
2025-05-08 19:49:50 +05:30
except Exception as e: # Error in this callback itself
self.log_signal.emit(f"❌ Error in _handle_future_result callback: {e}\n{traceback.format_exc(limit=2)}")
# Check if all tasks are done
if self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process:
# More robust check: ensure all submitted futures are actually done
all_done = all(f.done() for f in self.active_futures)
if all_done:
QApplication.processEvents() # Process any pending GUI events
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())
2025-05-06 22:49:19 +05:30
2025-05-07 07:20:40 +05:30
2025-05-06 22:08:27 +05:30
def set_ui_enabled(self, enabled):
2025-05-08 19:49:50 +05:30
# List of widgets to toggle enabled state
widgets_to_toggle = [
self.download_btn, self.link_input, self.dir_input, self.dir_button,
self.radio_all, self.radio_images, self.radio_videos,
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.external_links_checkbox, # MODIFIED: Keep this enabled
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.log_verbosity_button, # MODIFIED: Keep this enabled
self.manga_mode_checkbox
]
for widget in widgets_to_toggle:
if widget: # Check if widget exists
widget.setEnabled(enabled)
# --- ADDED: Explicitly keep these enabled ---
if self.external_links_checkbox:
self.external_links_checkbox.setEnabled(True)
if self.log_verbosity_button:
self.log_verbosity_button.setEnabled(True)
# --- END ADDED ---
# Handle dependent widgets
subfolders_currently_on = self.use_subfolders_checkbox.isChecked()
self.use_subfolder_per_post_checkbox.setEnabled(enabled and subfolders_currently_on)
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)
self.cancel_btn.setEnabled(not enabled) # Cancel is enabled when download is running
if enabled: # When re-enabling UI, refresh dependent states
self.update_ui_for_subfolders(subfolders_currently_on)
self.update_custom_folder_visibility()
self.update_page_range_enabled_state()
if self.manga_mode_checkbox:
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked())
2025-05-07 07:20:40 +05:30
2025-05-05 19:35:24 +05:30
def cancel_download(self):
2025-05-08 19:49:50 +05:30
if not self.cancel_btn.isEnabled() and not self.cancellation_event.is_set(): # Avoid multiple cancel calls
self.log_signal.emit(" No active download to cancel or already cancelling.")
return
2025-05-06 22:08:27 +05:30
2025-05-08 19:49:50 +05:30
self.log_signal.emit("⚠️ Requesting cancellation of download process...")
self.cancellation_event.set() # Signal all threads/workers to stop
if self.download_thread and self.download_thread.isRunning():
# For QThread, requestInterruption() is a polite request.
# The thread's run() loop must check isInterruptionRequested() or self.cancellation_event.
self.download_thread.requestInterruption()
self.log_signal.emit(" Signaled single download thread to interrupt.")
# --- MODIFICATION START: Initiate thread pool shutdown immediately ---
if self.thread_pool:
self.log_signal.emit(" Initiating immediate shutdown and cancellation of worker pool tasks...")
# Start shutdown non-blockingly, attempting to cancel futures
self.thread_pool.shutdown(wait=False, cancel_futures=True)
# --- MODIFICATION END ---
# --- ADDED: Clear link queue on cancel ---
self.external_link_queue.clear()
self._is_processing_external_link_queue = False
# --- END ADDED ---
self.cancel_btn.setEnabled(False) # Disable cancel button after initiating cancellation
2025-05-06 22:08:27 +05:30
self.progress_label.setText("Progress: Cancelling...")
2025-05-08 19:49:50 +05:30
self.file_progress_label.setText("")
# The download_finished method will be called eventually when threads finally exit.
def download_finished(self, total_downloaded, total_skipped, cancelled_by_user):
# This method is the final cleanup point, called by DownloadThread or _handle_future_result
status_message = "Cancelled by user" if cancelled_by_user else "Finished"
self.log_signal.emit("="*40 + f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n" + "="*40)
self.progress_label.setText(f"{status_message}: {total_downloaded} downloaded, {total_skipped} skipped.")
self.file_progress_label.setText("") # Clear file progress
# --- ADDED: Attempt to process any remaining links in queue if not cancelled ---
# This will now trigger the rapid display because _is_download_active() will be false
if not cancelled_by_user:
self._try_process_next_external_link()
# --- END ADDED ---
# Disconnect signals from single download thread if it was used
2025-05-06 22:08:27 +05:30
if self.download_thread:
2025-05-08 19:49:50 +05:30
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.finished_signal)
if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result)
# MODIFIED: Ensure disconnection from the correct handler
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 # Clear reference
# Shutdown thread pool if it exists and hasn't been cleared yet
# Use wait=True here to ensure cleanup before UI re-enables
2025-05-06 22:08:27 +05:30
if self.thread_pool:
2025-05-08 19:49:50 +05:30
self.log_signal.emit(" Ensuring worker thread pool is shut down...")
# Shutdown might have been initiated by cancel_download, but wait=True ensures completion.
self.thread_pool.shutdown(wait=True, cancel_futures=True)
2025-05-06 22:08:27 +05:30
self.thread_pool = None
2025-05-08 19:49:50 +05:30
self.active_futures = [] # Clear list of futures
# Clear cancellation event here AFTER threads have likely stopped checking it
# self.cancellation_event.clear()
# Let's clear it in start_download and reset_application_state instead for safety.
self.set_ui_enabled(True) # Re-enable UI
self.cancel_btn.setEnabled(False) # Disable cancel button
# --- ADDED: Method to toggle log verbosity ---
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)
# --- END ADDED ---
def reset_application_state(self):
is_running = (self.download_thread and self.download_thread.isRunning()) or \
(self.thread_pool is not None and any(not f.done() for f in self.active_futures if f is not None))
if is_running:
QMessageBox.warning(self, "Reset Error", "Cannot reset while a download is in progress. Please cancel the download first.")
return
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._reset_ui_to_defaults() # Reset UI elements to their initial state
self.main_log_output.clear()
self.external_log_output.clear()
if self.show_external_links: # Re-add header if shown
self.external_log_output.append("🔗 External Links Found:")
# --- ADDED: Clear link queue on reset ---
self.external_link_queue.clear()
self._is_processing_external_link_queue = False
# --- END ADDED ---
self.progress_label.setText("Progress: Idle")
self.file_progress_label.setText("")
# Clear session-specific data
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.external_links = [] # This list seems unused, keeping it commented
self.cancellation_event.clear() # Ensure cancellation event is reset
# --- ADDED: Reset log verbosity mode ---
self.basic_log_mode = False
if self.log_verbosity_button:
self.log_verbosity_button.setText("Show Basic Log")
# --- END ADDED ---
self.log_signal.emit("✅ Application reset complete.")
def _reset_ui_to_defaults(self):
# Reset all input fields
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")
# Reset radio buttons and checkboxes to defaults
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)
# Explicitly call update methods that control UI element states
self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked())
self.update_custom_folder_visibility()
self.update_page_range_enabled_state()
self.update_multithreading_label(self.thread_count_input.text())
if self.manga_mode_checkbox:
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked())
self.filter_character_list("") # Clear character list filter
# Reset button states
self.download_btn.setEnabled(True)
2025-05-05 19:35:24 +05:30
self.cancel_btn.setEnabled(False)
2025-05-08 19:49:50 +05:30
if self.reset_button: self.reset_button.setEnabled(True)
# Reset log verbosity button text
if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log")
2025-05-06 22:08:27 +05:30
2025-05-07 07:20:40 +05:30
def prompt_add_character(self, character_name):
2025-05-08 19:49:50 +05:30
global KNOWN_NAMES
# This method is called via a signal from a worker thread.
# It interacts with the GUI, so it's correctly placed in the GUI class.
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?",
2025-05-06 22:08:27 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
2025-05-07 07:20:40 +05:30
result = (reply == QMessageBox.Yes)
if result:
2025-05-08 19:49:50 +05:30
self.new_char_input.setText(character_name) # Populate input for add_new_character
# Call add_new_character, which now includes similarity checks and its own QMessageBox
# The result of add_new_character (True/False) reflects if it was actually added.
if self.add_new_character():
self.log_signal.emit(f"✅ Added '{character_name}' to known names via background prompt.")
2025-05-06 22:08:27 +05:30
else:
2025-05-08 19:49:50 +05:30
# add_new_character handles its own logging and popups if it fails or user cancels similarity warning
result = False # Update result if add_new_character decided not to add
self.log_signal.emit(f" Adding '{character_name}' via background prompt was declined or failed (e.g., similarity warning, duplicate).")
# Send the final outcome (whether it was added or user said yes initially but then cancelled)
2025-05-07 07:20:40 +05:30
self.character_prompt_response_signal.emit(result)
2025-05-06 22:08:27 +05:30
def receive_add_character_result(self, result):
2025-05-08 19:49:50 +05:30
# This method receives the result from prompt_add_character (after it has tried to add the name)
# and is typically connected to the worker thread's logic to unblock it.
with QMutexLocker(self.prompt_mutex): # Ensure thread-safe access if worker modifies shared state based on this
2025-05-06 22:08:27 +05:30
self._add_character_response = result
2025-05-08 19:49:50 +05:30
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'}")
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
if __name__ == '__main__':
2025-05-08 19:49:50 +05:30
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()
exit_code = qt_app.exec_()
print(f"Application finished with exit code: {exit_code}")
sys.exit(exit_code)
except SystemExit:
pass # Allow clean exit
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)