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. \n Single 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 : 4 px ; padding - bottom : 2 px ; color : #C0C0C0; }
QRadioButton , QCheckBox { spacing : 5 px ; color : #E0E0E0; padding-top: 4px; padding-bottom: 4px; }
QRadioButton : : indicator , QCheckBox : : indicator { width : 14 px ; height : 14 px ; }
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 : 5 px ; }
QSplitter : : handle : vertical { height : 5 px ; }
""" # 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 } \n Original 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 } \n Original Message: { formatted_link_text } " )
print ( f " GUI External Log Error (Append): { e } \n Original 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. \n Create 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 \n Proceeding 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). \n Add 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. \n It ' s not in your known names list (used for folder suggestions). \n Add 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 )