2438 lines
132 KiB
Python
Raw Normal View History

2025-05-05 19:35:24 +05:30
import sys
import os
import time
import requests
import re
2025-05-06 22:08:27 +05:30
import threading
2025-05-10 23:59:00 +05:30
import queue
2025-05-06 22:49:19 +05:30
import hashlib
2025-05-08 19:49:50 +05:30
import http.client
import traceback
2025-05-10 23:59:00 +05:30
import random
from collections import deque
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,
2025-05-12 10:54:31 +05:30
QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QDesktopWidget,
2025-05-10 11:07:27 +05:30
QRadioButton, QButtonGroup, QCheckBox, QSplitter, QSizePolicy, QDialog,
QFrame,
QAbstractButton
2025-05-05 19:35:24 +05:30
)
2025-05-12 10:54:31 +05:30
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QMutex, QMutexLocker, QObject, QTimer, QSettings, QStandardPaths
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
try:
print("Attempting to import from downloader_utils...")
2025-05-09 19:03:01 +05:30
from downloader_utils import (
2025-05-08 19:49:50 +05:30
KNOWN_NAMES,
2025-05-09 19:03:01 +05:30
clean_folder_name,
2025-05-08 19:49:50 +05:30
extract_post_info,
download_from_api,
PostProcessorSignals,
PostProcessorWorker,
2025-05-10 23:59:00 +05:30
DownloadThread as BackendDownloadThread,
2025-05-10 11:07:27 +05:30
SKIP_SCOPE_FILES,
SKIP_SCOPE_POSTS,
2025-05-10 23:59:00 +05:30
SKIP_SCOPE_BOTH,
2025-05-12 10:54:31 +05:30
CHAR_SCOPE_TITLE, # Added for completeness if used directly
CHAR_SCOPE_FILES, # Added
CHAR_SCOPE_BOTH # Added
2025-05-08 19:49:50 +05:30
)
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}")
KNOWN_NAMES = []
2025-05-10 23:59:00 +05:30
PostProcessorSignals = QObject
PostProcessorWorker = object
BackendDownloadThread = QThread
def clean_folder_name(n): return str(n)
def extract_post_info(u): return None, None, None
def download_from_api(*a, **k): yield []
2025-05-10 11:07:27 +05:30
SKIP_SCOPE_FILES = "files"
SKIP_SCOPE_POSTS = "posts"
SKIP_SCOPE_BOTH = "both"
2025-05-12 10:54:31 +05:30
CHAR_SCOPE_TITLE = "title"
CHAR_SCOPE_FILES = "files"
CHAR_SCOPE_BOTH = "both"
2025-05-10 23:59:00 +05:30
2025-05-08 19:49:50 +05:30
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)
2025-05-10 23:59:00 +05:30
sys.exit(1)
2025-05-05 19:35:24 +05:30
2025-05-09 19:03:01 +05:30
try:
2025-05-10 23:59:00 +05:30
from tour import TourDialog
2025-05-09 19:03:01 +05:30
print("Successfully imported TourDialog from tour.py.")
except ImportError as e:
print(f"--- TOUR IMPORT ERROR ---")
print(f"Failed to import TourDialog from 'tour.py': {e}")
print("Tour functionality will be unavailable.")
2025-05-10 23:59:00 +05:30
TourDialog = None
2025-05-09 19:03:01 +05:30
except Exception as e:
print(f"--- UNEXPECTED TOUR IMPORT ERROR ---")
print(f"An unexpected error occurred during tour import: {e}")
traceback.print_exc()
TourDialog = None
2025-05-10 23:59:00 +05:30
MAX_THREADS = 200
RECOMMENDED_MAX_THREADS = 50
MAX_FILE_THREADS_PER_POST_OR_WORKER = 10
2025-05-05 19:35:24 +05:30
2025-05-10 23:59:00 +05:30
HTML_PREFIX = "<!HTML!>"
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
CONFIG_APP_NAME_MAIN = "ApplicationSettings"
MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name"
SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1"
2025-05-12 10:54:31 +05:30
ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
2025-05-10 23:59:00 +05:30
CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1"
2025-05-12 10:54:31 +05:30
# CHAR_SCOPE_TITLE, CHAR_SCOPE_FILES, CHAR_SCOPE_BOTH are already defined or imported
DUPLICATE_FILE_MODE_KEY = "duplicateFileModeV1"
# DUPLICATE_MODE_RENAME is removed. Renaming only happens within a target folder if needed.
DUPLICATE_MODE_DELETE = "delete"
DUPLICATE_MODE_MOVE_TO_SUBFOLDER = "move" # New mode
2025-05-10 11:07:27 +05:30
2025-05-09 19:03:01 +05:30
2025-05-06 22:08:27 +05:30
class DownloaderApp(QWidget):
2025-05-10 23:59:00 +05:30
character_prompt_response_signal = pyqtSignal(bool)
log_signal = pyqtSignal(str)
add_character_prompt_signal = pyqtSignal(str)
overall_progress_signal = pyqtSignal(int, int)
finished_signal = pyqtSignal(int, int, bool, list)
external_link_signal = pyqtSignal(str, str, str, str)
2025-05-12 10:54:31 +05:30
# Changed to object to handle both (int, int) for single stream and list for multipart
file_progress_signal = pyqtSignal(str, object)
2025-05-05 19:35:24 +05:30
2025-05-06 22:08:27 +05:30
def __init__(self):
super().__init__()
2025-05-10 11:07:27 +05:30
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
2025-05-12 10:54:31 +05:30
# Determine path for Known.txt in user's app data directory
app_config_dir = ""
try:
# Use AppLocalDataLocation for user-specific, non-roaming data
app_data_root = QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation)
if not app_data_root: # Fallback if somehow empty
app_data_root = QStandardPaths.writableLocation(QStandardPaths.GenericDataLocation)
if app_data_root and CONFIG_ORGANIZATION_NAME:
app_config_dir = os.path.join(app_data_root, CONFIG_ORGANIZATION_NAME)
elif app_data_root: # If no org name, use a generic app name folder
app_config_dir = os.path.join(app_data_root, "KemonoDownloaderAppData") # Fallback app name
else: # Absolute fallback: current working directory (less ideal for bundled app)
app_config_dir = os.getcwd()
if not os.path.exists(app_config_dir):
os.makedirs(app_config_dir, exist_ok=True)
except Exception as e_path:
print(f"Error setting up app_config_dir: {e_path}. Defaulting to CWD for Known.txt.")
app_config_dir = os.getcwd() # Fallback
self.config_file = os.path.join(app_config_dir, "Known.txt")
2025-05-10 23:59:00 +05:30
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-10 11:07:27 +05:30
self.worker_signals = PostProcessorSignals()
2025-05-06 22:08:27 +05:30
self.prompt_mutex = QMutex()
self._add_character_response = None
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
self.downloaded_files = set()
self.downloaded_files_lock = threading.Lock()
self.downloaded_file_hashes = set()
self.downloaded_file_hashes_lock = threading.Lock()
self.show_external_links = False
self.external_link_queue = deque()
self._is_processing_external_link_queue = False
self._current_link_post_title = None
self.extracted_links_cache = []
self.basic_log_mode = False
self.log_verbosity_button = None
self.manga_rename_toggle_button = None
self.main_log_output = None
self.external_log_output = None
self.log_splitter = None
self.main_splitter = None
self.reset_button = None
self.progress_log_label = None
self.link_search_input = None
self.link_search_button = None
self.export_links_button = None
self.manga_mode_checkbox = None
self.radio_only_links = None
self.radio_only_archives = None
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
self.skip_scope_toggle_button = None
self.char_filter_scope_toggle_button = None
2025-05-10 11:07:27 +05:30
self.all_kept_original_filenames = []
self.manga_filename_style = self.settings.value(MANGA_FILENAME_STYLE_KEY, STYLE_POST_TITLE, type=str)
2025-05-10 23:59:00 +05:30
self.skip_words_scope = self.settings.value(SKIP_WORDS_SCOPE_KEY, SKIP_SCOPE_POSTS, type=str)
self.char_filter_scope = self.settings.value(CHAR_FILTER_SCOPE_KEY, CHAR_SCOPE_TITLE, type=str)
2025-05-12 10:54:31 +05:30
self.allow_multipart_download_setting = self.settings.value(ALLOW_MULTIPART_DOWNLOAD_KEY, False, type=bool) # Default to OFF
self.duplicate_file_mode = self.settings.value(DUPLICATE_FILE_MODE_KEY, DUPLICATE_MODE_DELETE, type=str) # Default to DELETE
print(f" Known.txt will be loaded/saved at: {self.config_file}")
2025-05-10 23:59:00 +05:30
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
self.load_known_names_from_util()
2025-05-12 10:54:31 +05:30
self.setWindowTitle("Kemono Downloader v3.2.0")
# self.setGeometry(150, 150, 1050, 820) # Initial geometry will be set after showing
2025-05-10 23:59:00 +05:30
self.setStyleSheet(self.get_dark_theme())
self.init_ui()
self._connect_signals()
2025-05-10 11:07:27 +05:30
2025-05-06 22:08:27 +05:30
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.")
2025-05-10 23:59:00 +05:30
if hasattr(self, 'character_input'):
2025-05-12 10:54:31 +05:30
self.character_input.setToolTip("Names, comma-separated. Group aliases: (alias1, alias2) for combined folder name 'alias1 alias2'. E.g., yor, (Boa, Hancock)")
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f" Manga filename style loaded: '{self.manga_filename_style}'")
self.log_signal.emit(f" Skip words scope loaded: '{self.skip_words_scope}'")
2025-05-10 23:59:00 +05:30
self.log_signal.emit(f" Character filter scope loaded: '{self.char_filter_scope}'")
2025-05-12 10:54:31 +05:30
self.log_signal.emit(f" Multi-part download preference loaded: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'}")
self.log_signal.emit(f" Duplicate file handling mode loaded: '{self.duplicate_file_mode.capitalize()}'")
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
if hasattr(self.worker_signals, 'progress_signal'):
self.worker_signals.progress_signal.connect(self.handle_main_log)
if hasattr(self.worker_signals, 'file_progress_signal'):
self.worker_signals.file_progress_signal.connect(self.update_file_progress_display)
if hasattr(self.worker_signals, 'external_link_signal'):
2025-05-09 19:03:01 +05:30
self.worker_signals.external_link_signal.connect(self.handle_external_link_signal)
2025-05-08 19:49:50 +05:30
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-10 23:59:00 +05:30
self.external_link_signal.connect(self.handle_external_link_signal)
self.file_progress_signal.connect(self.update_file_progress_display)
2025-05-10 11:07:27 +05:30
if hasattr(self, 'character_search_input'): self.character_search_input.textChanged.connect(self.filter_character_list)
if hasattr(self, 'external_links_checkbox'): self.external_links_checkbox.toggled.connect(self.update_external_links_setting)
if hasattr(self, 'thread_count_input'): self.thread_count_input.textChanged.connect(self.update_multithreading_label)
if hasattr(self, 'use_subfolder_per_post_checkbox'): self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders)
if hasattr(self, 'use_multithreading_checkbox'): self.use_multithreading_checkbox.toggled.connect(self._handle_multithreading_toggle)
if hasattr(self, 'radio_group') and self.radio_group:
self.radio_group.buttonToggled.connect(self._handle_filter_mode_change)
if self.reset_button: self.reset_button.clicked.connect(self.reset_application_state)
if self.log_verbosity_button: self.log_verbosity_button.clicked.connect(self.toggle_log_verbosity)
if self.link_search_button: self.link_search_button.clicked.connect(self._filter_links_log)
2025-05-09 19:03:01 +05:30
if self.link_search_input:
2025-05-10 23:59:00 +05:30
self.link_search_input.returnPressed.connect(self._filter_links_log)
self.link_search_input.textChanged.connect(self._filter_links_log)
2025-05-10 11:07:27 +05:30
if self.export_links_button: self.export_links_button.clicked.connect(self._export_links_to_file)
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
if self.manga_mode_checkbox: self.manga_mode_checkbox.toggled.connect(self.update_ui_for_manga_mode)
if self.manga_rename_toggle_button: self.manga_rename_toggle_button.clicked.connect(self._toggle_manga_filename_style)
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
if hasattr(self, 'link_input'):
self.link_input.textChanged.connect(lambda: self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False))
if self.skip_scope_toggle_button:
self.skip_scope_toggle_button.clicked.connect(self._cycle_skip_scope)
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
if self.char_filter_scope_toggle_button:
self.char_filter_scope_toggle_button.clicked.connect(self._cycle_char_filter_scope)
2025-05-12 10:54:31 +05:30
if hasattr(self, 'multipart_toggle_button'): self.multipart_toggle_button.clicked.connect(self._toggle_multipart_mode)
if hasattr(self, 'duplicate_mode_toggle_button'): self.duplicate_mode_toggle_button.clicked.connect(self._cycle_duplicate_mode)
2025-05-10 23:59:00 +05:30
2025-05-07 07:20:40 +05:30
def load_known_names_from_util(self):
2025-05-10 23:59:00 +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-10 23:59:00 +05:30
KNOWN_NAMES[:] = sorted(list(set(filter(None, raw_names))))
2025-05-08 19:49:50 +05:30
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-10 23:59:00 +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-10 23:59:00 +05:30
KNOWN_NAMES[:] = []
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
if hasattr(self, 'log_signal'): self.log_signal.emit(log_msg)
2025-05-10 11:07:27 +05:30
if hasattr(self, 'character_list'):
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-10 23:59:00 +05:30
global KNOWN_NAMES
2025-05-05 19:35:24 +05:30
try:
2025-05-08 19:49:50 +05:30
unique_sorted_names = sorted(list(set(filter(None, KNOWN_NAMES))))
2025-05-10 23:59:00 +05:30
KNOWN_NAMES[:] = unique_sorted_names
2025-05-08 19:49:50 +05:30
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-10 11:07:27 +05:30
if hasattr(self, 'log_signal'): 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-10 11:07:27 +05:30
if hasattr(self, 'log_signal'): 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-10 11:07:27 +05:30
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
2025-05-10 23:59:00 +05:30
self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope)
2025-05-12 10:54:31 +05:30
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
self.settings.setValue(DUPLICATE_FILE_MODE_KEY, self.duplicate_file_mode) # Save current mode
2025-05-10 23:59:00 +05:30
self.settings.sync()
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
should_exit = True
2025-05-10 23:59:00 +05:30
is_downloading = self._is_download_active()
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-10 23:59:00 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
2025-05-05 19:35:24 +05:30
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-10 11:07:27 +05:30
2025-05-12 10:54:31 +05:30
# Direct cancellation for exit - different from button cancel
self.cancellation_event.set()
if self.download_thread and self.download_thread.isRunning():
self.download_thread.requestInterruption()
self.log_signal.emit(" Signaled single download thread to interrupt.")
# For thread pool, we want to wait on exit.
2025-05-08 19:49:50 +05:30
if self.download_thread and self.download_thread.isRunning():
2025-05-12 10:54:31 +05:30
self.log_signal.emit(" Waiting for single download thread to finish...")
2025-05-10 23:59:00 +05:30
self.download_thread.wait(3000)
2025-05-08 19:49:50 +05:30
if self.download_thread.isRunning():
self.log_signal.emit(" ⚠️ Single download thread did not terminate gracefully.")
2025-05-12 10:54:31 +05:30
2025-05-08 19:49:50 +05:30
if self.thread_pool:
2025-05-12 10:54:31 +05:30
self.log_signal.emit(" Shutting down thread pool (waiting for completion)...")
2025-05-08 19:49:50 +05:30
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.log_signal.emit(" Thread pool shutdown complete.")
2025-05-10 23:59:00 +05:30
self.thread_pool = None
2025-05-12 10:54:31 +05:30
self.log_signal.emit(" Cancellation for exit complete.")
2025-05-05 19:35:24 +05:30
else:
2025-05-10 23:59:00 +05:30
should_exit = False
2025-05-06 22:08:27 +05:30
self.log_signal.emit(" Application exit cancelled.")
2025-05-10 23:59:00 +05:30
event.ignore()
return
2025-05-06 22:08:27 +05:30
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
if self.thread_pool:
self.log_signal.emit(" Final thread pool check: Shutting down...")
2025-05-10 23:59:00 +05:30
self.cancellation_event.set()
self.thread_pool.shutdown(wait=True, cancel_futures=True)
2025-05-08 19:49:50 +05:30
self.thread_pool = None
2025-05-06 22:08:27 +05:30
self.log_signal.emit("👋 Exiting application.")
2025-05-10 23:59:00 +05:30
event.accept()
2025-05-10 11:07:27 +05:30
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
self.main_splitter = QSplitter(Qt.Horizontal)
2025-05-10 23:59:00 +05:30
left_panel_widget = QWidget()
right_panel_widget = QWidget()
left_layout = QVBoxLayout(left_panel_widget)
right_layout = QVBoxLayout(right_panel_widget)
left_layout.setContentsMargins(10, 10, 10, 10)
right_layout.setContentsMargins(10, 10, 10, 10)
url_page_layout = QHBoxLayout()
url_page_layout.setContentsMargins(0,0,0,0)
2025-05-08 19:49:50 +05:30
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")
2025-05-10 23:59:00 +05:30
self.link_input.textChanged.connect(self.update_custom_folder_visibility)
url_page_layout.addWidget(self.link_input, 1)
2025-05-08 19:49:50 +05:30
self.page_range_label = QLabel("Page Range:")
2025-05-10 23:59:00 +05:30
self.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;")
2025-05-08 19:49:50 +05:30
self.start_page_input = QLineEdit()
self.start_page_input.setPlaceholderText("Start")
2025-05-10 23:59:00 +05:30
self.start_page_input.setFixedWidth(50)
self.start_page_input.setValidator(QIntValidator(1, 99999))
self.to_label = QLabel("to")
2025-05-08 19:49:50 +05:30
self.end_page_input = QLineEdit()
self.end_page_input.setPlaceholderText("End")
self.end_page_input.setFixedWidth(50)
2025-05-10 11:07:27 +05:30
self.end_page_input.setValidator(QIntValidator(1, 99999))
2025-05-08 19:49:50 +05:30
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)
2025-05-10 23:59:00 +05:30
left_layout.addLayout(url_page_layout)
2025-05-08 19:49:50 +05:30
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")
2025-05-10 23:59:00 +05:30
self.dir_button = QPushButton("Browse...")
2025-05-05 19:35:24 +05:30
self.dir_button.clicked.connect(self.browse_directory)
2025-05-10 23:59:00 +05:30
dir_layout = QHBoxLayout()
dir_layout.addWidget(self.dir_input, 1)
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
2025-05-10 11:07:27 +05:30
self.filters_and_custom_folder_container_widget = QWidget()
filters_and_custom_folder_layout = QHBoxLayout(self.filters_and_custom_folder_container_widget)
2025-05-10 23:59:00 +05:30
filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0)
filters_and_custom_folder_layout.setSpacing(10)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
self.character_filter_widget = QWidget()
2025-05-10 11:07:27 +05:30
character_filter_v_layout = QVBoxLayout(self.character_filter_widget)
2025-05-10 23:59:00 +05:30
character_filter_v_layout.setContentsMargins(0,0,0,0)
character_filter_v_layout.setSpacing(2)
2025-05-08 19:49:50 +05:30
self.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):")
2025-05-10 23:59:00 +05:30
character_filter_v_layout.addWidget(self.character_label)
char_input_and_button_layout = QHBoxLayout()
char_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
char_input_and_button_layout.setSpacing(10)
2025-05-05 19:35:24 +05:30
self.character_input = QLineEdit()
2025-05-12 10:54:31 +05:30
self.character_input.setPlaceholderText("e.g., yor, Tifa, (Reyna, Sage)")
2025-05-10 23:59:00 +05:30
char_input_and_button_layout.addWidget(self.character_input, 3)
self.char_filter_scope_toggle_button = QPushButton()
self._update_char_filter_scope_button_text()
self.char_filter_scope_toggle_button.setToolTip("Click to cycle character filter scope (Files -> Title -> Both)")
self.char_filter_scope_toggle_button.setStyleSheet("padding: 6px 10px;")
self.char_filter_scope_toggle_button.setMinimumWidth(100)
char_input_and_button_layout.addWidget(self.char_filter_scope_toggle_button, 1)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
character_filter_v_layout.addLayout(char_input_and_button_layout)
self.custom_folder_widget = QWidget()
2025-05-10 11:07:27 +05:30
custom_folder_v_layout = QVBoxLayout(self.custom_folder_widget)
2025-05-10 23:59:00 +05:30
custom_folder_v_layout.setContentsMargins(0,0,0,0)
2025-05-10 11:07:27 +05:30
custom_folder_v_layout.setSpacing(2)
self.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):")
self.custom_folder_input = QLineEdit()
self.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder")
custom_folder_v_layout.addWidget(self.custom_folder_label)
custom_folder_v_layout.addWidget(self.custom_folder_input)
2025-05-10 23:59:00 +05:30
self.custom_folder_widget.setVisible(False)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
filters_and_custom_folder_layout.addWidget(self.character_filter_widget, 1)
filters_and_custom_folder_layout.addWidget(self.custom_folder_widget, 1)
2025-05-10 11:07:27 +05:30
left_layout.addWidget(self.filters_and_custom_folder_container_widget)
2025-05-08 19:49:50 +05:30
2025-05-12 10:54:31 +05:30
# --- Word Manipulation Section (Skip Words & Remove from Filename) ---
word_manipulation_container_widget = QWidget()
word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget)
word_manipulation_outer_layout.setContentsMargins(0,0,0,0) # No margins for the outer container
word_manipulation_outer_layout.setSpacing(15) # Spacing between the two vertical groups
# Group 1: Skip Words (Left, ~70% space)
skip_words_widget = QWidget()
skip_words_vertical_layout = QVBoxLayout(skip_words_widget)
skip_words_vertical_layout.setContentsMargins(0,0,0,0) # No margins for the inner group
skip_words_vertical_layout.setSpacing(2) # Small spacing between label and input row
skip_words_label = QLabel("🚫 Skip with Words (comma-separated):")
skip_words_vertical_layout.addWidget(skip_words_label)
skip_input_and_button_layout = QHBoxLayout()
2025-05-10 23:59:00 +05:30
skip_input_and_button_layout = QHBoxLayout()
2025-05-10 11:07:27 +05:30
skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
skip_input_and_button_layout.setSpacing(10)
2025-05-06 22:08:27 +05:30
self.skip_words_input = QLineEdit()
self.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
2025-05-12 10:54:31 +05:30
skip_input_and_button_layout.addWidget(self.skip_words_input, 1) # Input field takes available space
2025-05-10 23:59:00 +05:30
self.skip_scope_toggle_button = QPushButton()
self._update_skip_scope_button_text()
2025-05-10 11:07:27 +05:30
self.skip_scope_toggle_button.setToolTip("Click to cycle skip scope (Files -> Posts -> Both)")
2025-05-10 23:59:00 +05:30
self.skip_scope_toggle_button.setStyleSheet("padding: 6px 10px;")
self.skip_scope_toggle_button.setMinimumWidth(100)
2025-05-12 10:54:31 +05:30
skip_input_and_button_layout.addWidget(self.skip_scope_toggle_button, 0) # Button takes its minimum
skip_words_vertical_layout.addLayout(skip_input_and_button_layout)
word_manipulation_outer_layout.addWidget(skip_words_widget, 7) # 70% stretch for left group
# Group 2: Remove Words from name (Right, ~30% space)
remove_words_widget = QWidget()
remove_words_vertical_layout = QVBoxLayout(remove_words_widget)
remove_words_vertical_layout.setContentsMargins(0,0,0,0) # No margins for the inner group
remove_words_vertical_layout.setSpacing(2)
self.remove_from_filename_label = QLabel("✂️ Remove Words from name:")
remove_words_vertical_layout.addWidget(self.remove_from_filename_label)
self.remove_from_filename_input = QLineEdit()
self.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD") # Placeholder for the new field
remove_words_vertical_layout.addWidget(self.remove_from_filename_input)
word_manipulation_outer_layout.addWidget(remove_words_widget, 3) # 30% stretch for right group
left_layout.addWidget(word_manipulation_container_widget)
# --- End Word Manipulation Section ---
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
file_filter_layout = QVBoxLayout()
file_filter_layout.setContentsMargins(0,10,0,0)
file_filter_layout.addWidget(QLabel("Filter Files:"))
radio_button_layout = QHBoxLayout()
radio_button_layout.setSpacing(10)
self.radio_group = QButtonGroup(self)
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-10 23:59:00 +05:30
self.radio_only_archives = QRadioButton("📦 Only Archives")
2025-05-10 11:07:27 +05:30
self.radio_only_links = QRadioButton("🔗 Only Links")
2025-05-10 23:59:00 +05:30
self.radio_all.setChecked(True)
2025-05-05 19:35:24 +05:30
self.radio_group.addButton(self.radio_all)
self.radio_group.addButton(self.radio_images)
self.radio_group.addButton(self.radio_videos)
2025-05-10 23:59:00 +05:30
self.radio_group.addButton(self.radio_only_archives)
2025-05-10 11:07:27 +05:30
self.radio_group.addButton(self.radio_only_links)
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)
2025-05-10 23:59:00 +05:30
radio_button_layout.addWidget(self.radio_only_archives)
2025-05-10 11:07:27 +05:30
radio_button_layout.addWidget(self.radio_only_links)
2025-05-10 23:59:00 +05:30
radio_button_layout.addStretch(1)
file_filter_layout.addLayout(radio_button_layout)
left_layout.addLayout(file_filter_layout)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
checkboxes_group_layout = QVBoxLayout()
checkboxes_group_layout.setSpacing(10)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
row1_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
row1_layout.setSpacing(10)
2025-05-06 22:08:27 +05:30
self.skip_zip_checkbox = QCheckBox("Skip .zip")
2025-05-10 23:59:00 +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-10 23:59:00 +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")
2025-05-10 23:59:00 +05:30
self.download_thumbnails_checkbox.setChecked(False)
2025-05-08 19:49:50 +05:30
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)")
2025-05-10 23:59:00 +05:30
self.compress_images_checkbox.setChecked(False)
2025-05-06 22:08:27 +05:30
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)
2025-05-10 23:59:00 +05:30
row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(row1_layout)
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
advanced_settings_label = QLabel("⚙️ Advanced Settings:")
2025-05-08 19:49:50 +05:30
checkboxes_group_layout.addWidget(advanced_settings_label)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
advanced_row1_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
advanced_row1_layout.setSpacing(10)
self.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title")
2025-05-10 23:59:00 +05:30
self.use_subfolders_checkbox.setChecked(True)
self.use_subfolders_checkbox.toggled.connect(self.update_ui_for_subfolders)
2025-05-08 19:49:50 +05:30
advanced_row1_layout.addWidget(self.use_subfolders_checkbox)
self.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
2025-05-10 23:59:00 +05:30
self.use_subfolder_per_post_checkbox.setChecked(False)
self.use_subfolder_per_post_checkbox.setToolTip(
"Creates a subfolder for each post. If 'Separate Folders' is also on, it's inside the character/title folder."
)
self.use_subfolder_per_post_checkbox.toggled.connect(self.update_ui_for_subfolders)
2025-05-08 19:49:50 +05:30
advanced_row1_layout.addWidget(self.use_subfolder_per_post_checkbox)
2025-05-10 23:59:00 +05:30
advanced_row1_layout.addStretch(1)
2025-05-08 19:49:50 +05:30
checkboxes_group_layout.addLayout(advanced_row1_layout)
2025-05-10 23:59:00 +05:30
advanced_row2_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
advanced_row2_layout.setSpacing(10)
2025-05-10 11:07:27 +05:30
multithreading_layout = QHBoxLayout()
2025-05-10 23:59:00 +05:30
multithreading_layout.setContentsMargins(0,0,0,0)
2025-05-08 19:49:50 +05:30
self.use_multithreading_checkbox = QCheckBox("Use Multithreading")
2025-05-10 23:59:00 +05:30
self.use_multithreading_checkbox.setChecked(True)
self.use_multithreading_checkbox.setToolTip(
2025-05-10 11:07:27 +05:30
"Enables concurrent operations. See 'Threads' input for details."
)
2025-05-08 19:49:50 +05:30
multithreading_layout.addWidget(self.use_multithreading_checkbox)
2025-05-10 23:59:00 +05:30
self.thread_count_label = QLabel("Threads:")
2025-05-08 19:49:50 +05:30
multithreading_layout.addWidget(self.thread_count_label)
2025-05-10 23:59:00 +05:30
self.thread_count_input = QLineEdit()
self.thread_count_input.setFixedWidth(40)
self.thread_count_input.setText("4")
self.thread_count_input.setToolTip(
2025-05-10 11:07:27 +05:30
f"Number of concurrent operations.\n"
f"- Single Post: Concurrent file downloads (1-{MAX_FILE_THREADS_PER_POST_OR_WORKER} recommended).\n"
f"- Creator Feed: Concurrent post processing (1-{MAX_THREADS}).\n"
f" File downloads per post worker also use this value (1-{MAX_FILE_THREADS_PER_POST_OR_WORKER} recommended)."
)
2025-05-10 23:59:00 +05:30
self.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS))
2025-05-08 19:49:50 +05:30
multithreading_layout.addWidget(self.thread_count_input)
2025-05-10 23:59:00 +05:30
advanced_row2_layout.addLayout(multithreading_layout)
2025-05-08 19:49:50 +05:30
self.external_links_checkbox = QCheckBox("Show External Links in Log")
2025-05-10 23:59:00 +05:30
self.external_links_checkbox.setChecked(False)
2025-05-08 19:49:50 +05:30
advanced_row2_layout.addWidget(self.external_links_checkbox)
2025-05-09 19:03:01 +05:30
self.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
2025-05-10 11:07:27 +05:30
self.manga_mode_checkbox.setToolTip("Downloads posts from oldest to newest and renames files based on post title (for creator feeds only).")
2025-05-10 23:59:00 +05:30
self.manga_mode_checkbox.setChecked(False)
2025-05-12 10:54:31 +05:30
advanced_row2_layout.addWidget(self.manga_mode_checkbox) # Keep manga mode checkbox here
2025-05-10 23:59:00 +05:30
advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout)
left_layout.addLayout(checkboxes_group_layout)
2025-05-09 19:03:01 +05:30
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +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-10 23:59:00 +05:30
self.download_btn.setStyleSheet("padding: 8px 15px; font-weight: bold;")
self.download_btn.clicked.connect(self.start_download)
2025-05-12 10:54:31 +05:30
self.cancel_btn = QPushButton("❌ Cancel & Reset UI") # Updated button text for clarity
2025-05-10 23:59:00 +05:30
self.cancel_btn.setEnabled(False)
2025-05-12 10:54:31 +05:30
self.cancel_btn.clicked.connect(self.cancel_download_button_action) # Changed connection
2025-05-05 19:35:24 +05:30
btn_layout.addWidget(self.download_btn)
btn_layout.addWidget(self.cancel_btn)
2025-05-10 23:59:00 +05:30
left_layout.addLayout(btn_layout)
left_layout.addSpacing(10)
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +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-10 23:59:00 +05:30
self.character_search_input = QLineEdit()
2025-05-06 22:49:19 +05:30
self.character_search_input.setPlaceholderText("Search characters...")
2025-05-10 23:59:00 +05:30
known_chars_label_layout.addWidget(self.known_chars_label, 1)
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-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
self.character_list = QListWidget()
self.character_list.setSelectionMode(QListWidget.ExtendedSelection)
left_layout.addWidget(self.character_list, 1)
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
char_manage_layout = QHBoxLayout()
2025-05-08 19:49:50 +05:30
char_manage_layout.setSpacing(10)
2025-05-10 23:59:00 +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-10 23:59:00 +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)
self.new_char_input.returnPressed.connect(self.add_char_button.click)
self.delete_char_button.clicked.connect(self.delete_selected_character)
char_manage_layout.addWidget(self.new_char_input, 2)
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)
2025-05-10 23:59:00 +05:30
left_layout.addLayout(char_manage_layout)
left_layout.addStretch(0)
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
log_title_layout = QHBoxLayout()
self.progress_log_label = QLabel("📜 Progress Log:")
2025-05-09 19:03:01 +05:30
log_title_layout.addWidget(self.progress_log_label)
2025-05-10 23:59:00 +05:30
log_title_layout.addStretch(1)
2025-05-09 19:03:01 +05:30
self.link_search_input = QLineEdit()
self.link_search_input.setPlaceholderText("Search Links...")
2025-05-10 23:59:00 +05:30
self.link_search_input.setVisible(False)
2025-05-10 11:07:27 +05:30
self.link_search_input.setFixedWidth(150)
2025-05-09 19:03:01 +05:30
log_title_layout.addWidget(self.link_search_input)
2025-05-10 23:59:00 +05:30
self.link_search_button = QPushButton("🔍")
2025-05-09 19:03:01 +05:30
self.link_search_button.setToolTip("Filter displayed links")
2025-05-10 23:59:00 +05:30
self.link_search_button.setVisible(False)
2025-05-09 19:03:01 +05:30
self.link_search_button.setFixedWidth(30)
2025-05-10 23:59:00 +05:30
self.link_search_button.setStyleSheet("padding: 4px 4px;")
2025-05-09 19:03:01 +05:30
log_title_layout.addWidget(self.link_search_button)
2025-05-10 23:59:00 +05:30
self.manga_rename_toggle_button = QPushButton()
self.manga_rename_toggle_button.setVisible(False)
self.manga_rename_toggle_button.setFixedWidth(140)
2025-05-10 11:07:27 +05:30
self.manga_rename_toggle_button.setStyleSheet("padding: 4px 8px;")
2025-05-10 23:59:00 +05:30
self._update_manga_filename_style_button_text()
2025-05-10 11:07:27 +05:30
log_title_layout.addWidget(self.manga_rename_toggle_button)
2025-05-12 10:54:31 +05:30
self.multipart_toggle_button = QPushButton() # Create the button
self.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.")
self.multipart_toggle_button.setFixedWidth(130) # Adjust width as needed
self.multipart_toggle_button.setStyleSheet("padding: 4px 8px;") # Added padding
self._update_multipart_toggle_button_text() # Set initial text
log_title_layout.addWidget(self.multipart_toggle_button) # Add to layout
self.duplicate_mode_toggle_button = QPushButton()
self.duplicate_mode_toggle_button.setToolTip("Toggle how duplicate filenames are handled (Rename or Delete).")
self.duplicate_mode_toggle_button.setFixedWidth(150) # Adjust width
self.duplicate_mode_toggle_button.setStyleSheet("padding: 4px 8px;") # Added padding
self._update_duplicate_mode_button_text() # Set initial text
log_title_layout.addWidget(self.duplicate_mode_toggle_button)
2025-05-10 23:59:00 +05:30
self.log_verbosity_button = QPushButton("Show Basic Log")
2025-05-08 19:49:50 +05:30
self.log_verbosity_button.setToolTip("Toggle between full and basic log details.")
2025-05-10 23:59:00 +05:30
self.log_verbosity_button.setFixedWidth(110)
2025-05-08 19:49:50 +05:30
self.log_verbosity_button.setStyleSheet("padding: 4px 8px;")
log_title_layout.addWidget(self.log_verbosity_button)
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
self.reset_button = QPushButton("🔄 Reset")
2025-05-08 19:49:50 +05:30
self.reset_button.setToolTip("Reset all inputs and logs to default state (only when idle).")
self.reset_button.setFixedWidth(80)
2025-05-10 11:07:27 +05:30
self.reset_button.setStyleSheet("padding: 4px 8px;")
2025-05-08 19:49:50 +05:30
log_title_layout.addWidget(self.reset_button)
2025-05-10 23:59:00 +05:30
right_layout.addLayout(log_title_layout)
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
self.log_splitter = QSplitter(Qt.Vertical)
self.main_log_output = QTextEdit()
self.main_log_output.setReadOnly(True)
self.main_log_output.setLineWrapMode(QTextEdit.NoWrap)
2025-05-08 19:49:50 +05:30
self.main_log_output.setStyleSheet("""
2025-05-10 11:07:27 +05:30
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; }""")
2025-05-10 23:59:00 +05:30
self.external_log_output = QTextEdit()
2025-05-08 19:49:50 +05:30
self.external_log_output.setReadOnly(True)
2025-05-10 11:07:27 +05:30
self.external_log_output.setLineWrapMode(QTextEdit.NoWrap)
2025-05-08 19:49:50 +05:30
self.external_log_output.setStyleSheet("""
2025-05-10 11:07:27 +05:30
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; }""")
2025-05-10 23:59:00 +05:30
self.external_log_output.hide()
self.log_splitter.addWidget(self.main_log_output)
self.log_splitter.addWidget(self.external_log_output)
self.log_splitter.setSizes([self.height(), 0])
right_layout.addWidget(self.log_splitter, 1)
export_button_layout = QHBoxLayout()
export_button_layout.addStretch(1)
2025-05-09 19:03:01 +05:30
self.export_links_button = QPushButton("Export Links")
self.export_links_button.setToolTip("Export all extracted links to a .txt file.")
self.export_links_button.setFixedWidth(100)
self.export_links_button.setStyleSheet("padding: 4px 8px; margin-top: 5px;")
2025-05-10 23:59:00 +05:30
self.export_links_button.setEnabled(False)
self.export_links_button.setVisible(False)
2025-05-09 19:03:01 +05:30
export_button_layout.addWidget(self.export_links_button)
2025-05-10 11:07:27 +05:30
right_layout.addLayout(export_button_layout)
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
self.progress_label = QLabel("Progress: Idle")
2025-05-06 22:08:27 +05:30
self.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;")
right_layout.addWidget(self.progress_label)
2025-05-10 23:59:00 +05:30
self.file_progress_label = QLabel("")
self.file_progress_label.setWordWrap(True)
2025-05-08 19:49:50 +05:30
self.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(self.file_progress_label)
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
self.main_splitter.addWidget(left_panel_widget)
self.main_splitter.addWidget(right_panel_widget)
2025-05-10 11:07:27 +05:30
initial_width = self.width()
left_width = int(initial_width * 0.35)
2025-05-08 19:49:50 +05:30
right_width = initial_width - left_width
self.main_splitter.setSizes([left_width, right_width])
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
top_level_layout = QHBoxLayout(self)
top_level_layout.setContentsMargins(0,0,0,0)
top_level_layout.addWidget(self.main_splitter)
2025-05-08 19:49:50 +05:30
2025-05-06 22:08:27 +05:30
self.update_ui_for_subfolders(self.use_subfolders_checkbox.isChecked())
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())
2025-05-10 23:59:00 +05:30
self.update_page_range_enabled_state()
if self.manga_mode_checkbox:
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked())
if hasattr(self, 'link_input'): self.link_input.textChanged.connect(self.update_page_range_enabled_state)
self.load_known_names_from_util()
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
if hasattr(self, 'radio_group') and self.radio_group.checkedButton():
self._handle_filter_mode_change(self.radio_group.checkedButton(), True)
self._update_manga_filename_style_button_text()
self._update_skip_scope_button_text()
self._update_char_filter_scope_button_text()
2025-05-12 10:54:31 +05:30
self._update_duplicate_mode_button_text()
def _center_on_screen(self):
"""Centers the widget on the screen."""
try:
screen_geometry = QDesktopWidget().screenGeometry()
widget_geometry = self.frameGeometry()
widget_geometry.moveCenter(screen_geometry.center())
self.move(widget_geometry.topLeft())
except Exception as e:
self.log_signal.emit(f"⚠️ Error centering window: {e}")
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
def get_dark_theme(self):
return """
2025-05-08 19:49:50 +05:30
QWidget { background-color: #2E2E2E; color: #E0E0E0; font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; }
QLineEdit, QListWidget { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; }
QTextEdit { background-color: #3C3F41; border: 1px solid #5A5A5A; padding: 5px; color: #F0F0F0; border-radius: 4px; }
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; min-height: 22px; }
QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; }
QPushButton:pressed { background-color: #4A4A4A; }
QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; }
QLabel { font-weight: bold; padding-top: 4px; padding-bottom: 2px; color: #C0C0C0; }
QRadioButton, QCheckBox { spacing: 5px; color: #E0E0E0; padding-top: 4px; padding-bottom: 4px; }
QRadioButton::indicator, QCheckBox::indicator { width: 14px; height: 14px; }
QListWidget { alternate-background-color: #353535; border: 1px solid #5A5A5A; }
QListWidget::item:selected { background-color: #007ACC; color: #FFFFFF; }
QToolTip { background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 4px; border-radius: 3px; }
2025-05-10 23:59:00 +05:30
QSplitter::handle { background-color: #5A5A5A; }
2025-05-08 19:49:50 +05:30
QSplitter::handle:horizontal { width: 5px; }
QSplitter::handle:vertical { height: 5px; }
2025-05-10 23:59:00 +05:30
QFrame[frameShape="4"], QFrame[frameShape="5"] {
border: 1px solid #4A4A4A;
2025-05-10 11:07:27 +05:30
border-radius: 3px;
}
"""
2025-05-08 19:49:50 +05:30
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-10 23:59:00 +05:30
if folder:
self.dir_input.setText(folder)
2025-05-05 19:35:24 +05:30
2025-05-08 19:49:50 +05:30
def handle_main_log(self, message):
2025-05-10 23:59:00 +05:30
is_html_message = message.startswith(HTML_PREFIX)
2025-05-10 11:07:27 +05:30
display_message = message
use_html = False
2025-05-09 19:03:01 +05:30
if is_html_message:
2025-05-10 23:59:00 +05:30
display_message = message[len(HTML_PREFIX):]
2025-05-09 19:03:01 +05:30
use_html = True
2025-05-10 23:59:00 +05:30
elif self.basic_log_mode:
2025-05-08 19:49:50 +05:30
basic_keywords = [
2025-05-10 23:59:00 +05:30
'🚀 starting download', '🏁 download finished', '🏁 download cancelled',
'', '⚠️', '✅ all posts processed', '✅ reached end of posts',
'summary:', 'progress:', '[fetcher]',
'critical error', 'import error', 'error', 'fail', 'timeout',
'unsupported url', 'invalid url', 'no posts found', 'could not create directory',
'missing dependency', 'high thread count', 'manga mode filter warning',
'duplicate name', 'potential name conflict', 'invalid filter name',
'no valid character filters'
2025-05-08 19:49:50 +05:30
]
2025-05-10 23:59:00 +05:30
message_lower = message.lower()
2025-05-08 22:13:12 +05:30
if not any(keyword in message_lower for keyword in basic_keywords):
if not message.strip().startswith("✅ Saved:") and \
not message.strip().startswith("✅ Added") and \
not message.strip().startswith("✅ Application reset complete"):
2025-05-10 23:59:00 +05:30
return
2025-05-10 11:07:27 +05:30
2025-05-06 22:08:27 +05:30
try:
2025-05-09 19:03:01 +05:30
safe_message = str(display_message).replace('\x00', '[NULL]')
if use_html:
2025-05-10 23:59:00 +05:30
self.main_log_output.insertHtml(safe_message)
2025-05-09 19:03:01 +05:30
else:
2025-05-10 23:59:00 +05:30
self.main_log_output.append(safe_message)
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
scrollbar = self.main_log_output.verticalScrollBar()
2025-05-10 23:59:00 +05:30
if scrollbar.value() >= scrollbar.maximum() - 30:
scrollbar.setValue(scrollbar.maximum())
2025-05-06 22:08:27 +05:30
except Exception as e:
2025-05-08 19:49:50 +05:30
print(f"GUI Main Log Error: {e}\nOriginal Message: {message}")
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
def _is_download_active(self):
single_thread_active = self.download_thread and self.download_thread.isRunning()
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
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
def handle_external_link_signal(self, post_title, link_text, link_url, platform):
2025-05-09 19:03:01 +05:30
link_data = (post_title, link_text, link_url, platform)
2025-05-10 23:59:00 +05:30
self.external_link_queue.append(link_data)
2025-05-09 19:03:01 +05:30
if self.radio_only_links and self.radio_only_links.isChecked():
2025-05-10 23:59:00 +05:30
self.extracted_links_cache.append(link_data)
self._try_process_next_external_link()
2025-05-08 19:49:50 +05:30
def _try_process_next_external_link(self):
2025-05-09 19:03:01 +05:30
if self._is_processing_external_link_queue or not self.external_link_queue:
2025-05-10 11:07:27 +05:30
return
2025-05-09 19:03:01 +05:30
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
2025-05-10 11:07:27 +05:30
should_display_in_external_log = self.show_external_links and not is_only_links_mode
if not (is_only_links_mode or should_display_in_external_log):
2025-05-10 23:59:00 +05:30
self._is_processing_external_link_queue = False
if self.external_link_queue:
QTimer.singleShot(0, self._try_process_next_external_link)
2025-05-09 19:03:01 +05:30
return
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
self._is_processing_external_link_queue = True
link_data = self.external_link_queue.popleft()
2025-05-09 19:03:01 +05:30
if is_only_links_mode:
2025-05-10 23:59:00 +05:30
delay_ms = 80
2025-05-09 19:03:01 +05:30
QTimer.singleShot(delay_ms, lambda data=link_data: self._display_and_schedule_next(data))
2025-05-10 23:59:00 +05:30
elif self._is_download_active():
delay_ms = random.randint(4000, 8000)
2025-05-09 19:03:01 +05:30
QTimer.singleShot(delay_ms, lambda data=link_data: self._display_and_schedule_next(data))
2025-05-10 23:59:00 +05:30
else:
2025-05-09 19:03:01 +05:30
QTimer.singleShot(0, lambda data=link_data: self._display_and_schedule_next(data))
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
2025-05-09 19:03:01 +05:30
def _display_and_schedule_next(self, link_data):
2025-05-10 11:07:27 +05:30
post_title, link_text, link_url, platform = link_data
2025-05-09 19:03:01 +05:30
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
max_link_text_len = 35
display_text = link_text[:max_link_text_len].strip() + "..." if len(link_text) > max_link_text_len else link_text
formatted_link_info = f"{display_text} - {link_url} - {platform}"
2025-05-10 23:59:00 +05:30
separator = "-" * 45
2025-05-09 19:03:01 +05:30
if is_only_links_mode:
2025-05-10 23:59:00 +05:30
if post_title != self._current_link_post_title:
self.log_signal.emit(HTML_PREFIX + "<br>" + separator + "<br>")
title_html = f'<b style="color: #87CEEB;">{post_title}</b><br>'
self.log_signal.emit(HTML_PREFIX + title_html)
self._current_link_post_title = post_title
self.log_signal.emit(formatted_link_info)
elif self.show_external_links:
self._append_to_external_log(formatted_link_info, separator)
2025-05-08 19:49:50 +05:30
self._is_processing_external_link_queue = False
2025-05-10 11:07:27 +05:30
self._try_process_next_external_link()
2025-05-08 19:49:50 +05:30
2025-05-09 19:03:01 +05:30
def _append_to_external_log(self, formatted_link_text, separator):
if not (self.external_log_output and self.external_log_output.isVisible()):
2025-05-10 23:59:00 +05:30
return
2025-05-08 19:49:50 +05:30
try:
self.external_log_output.append(formatted_link_text)
2025-05-10 23:59:00 +05:30
self.external_log_output.append("")
2025-05-08 19:49:50 +05:30
scrollbar = self.external_log_output.verticalScrollBar()
2025-05-10 23:59:00 +05:30
if scrollbar.value() >= scrollbar.maximum() - 50:
scrollbar.setValue(scrollbar.maximum())
2025-05-08 19:49:50 +05:30
except Exception as e:
2025-05-10 23:59:00 +05:30
self.log_signal.emit(f"GUI External Log Append Error: {e}\nOriginal Message: {formatted_link_text}")
2025-05-08 19:49:50 +05:30
print(f"GUI External Log Error (Append): {e}\nOriginal Message: {formatted_link_text}")
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
2025-05-12 10:54:31 +05:30
def update_file_progress_display(self, filename, progress_info):
if not filename and progress_info is None: # Explicit clear
2025-05-10 23:59:00 +05:30
self.file_progress_label.setText("")
2025-05-08 19:49:50 +05:30
return
2025-05-12 10:54:31 +05:30
if isinstance(progress_info, list): # Multi-part progress (list of chunk dicts)
if not progress_info: # Empty list
self.file_progress_label.setText(f"File: {filename} - Initializing parts...")
return
2025-05-09 19:03:01 +05:30
2025-05-12 10:54:31 +05:30
total_downloaded_overall = sum(cs.get('downloaded', 0) for cs in progress_info)
# total_file_size_overall should ideally be from progress_data['total_file_size']
# For now, we sum chunk totals. This assumes all chunks are for the same file.
total_file_size_overall = sum(cs.get('total', 0) for cs in progress_info)
active_chunks_count = 0
combined_speed_bps = 0
for cs in progress_info:
if cs.get('active', False):
active_chunks_count += 1
combined_speed_bps += cs.get('speed_bps', 0)
dl_mb = total_downloaded_overall / (1024 * 1024)
total_mb = total_file_size_overall / (1024 * 1024)
speed_MBps = (combined_speed_bps / 8) / (1024 * 1024)
progress_text = f"DL '{filename[:20]}...': {dl_mb:.1f}/{total_mb:.1f} MB ({active_chunks_count} parts @ {speed_MBps:.2f} MB/s)"
self.file_progress_label.setText(progress_text)
elif isinstance(progress_info, tuple) and len(progress_info) == 2: # Single stream (downloaded_bytes, total_bytes)
downloaded_bytes, total_bytes = progress_info
if not filename and total_bytes == 0 and downloaded_bytes == 0: # Clear if no info
self.file_progress_label.setText("")
return
2025-05-08 19:49:50 +05:30
2025-05-12 10:54:31 +05:30
max_fn_len = 25
disp_fn = filename if len(filename) <= max_fn_len else filename[:max_fn_len-3].strip()+"..."
dl_mb = downloaded_bytes / (1024*1024)
prog_text_base = f"Downloading '{disp_fn}' ({dl_mb:.1f}MB"
if total_bytes > 0:
tot_mb = total_bytes / (1024*1024)
prog_text_base += f" / {tot_mb:.1f}MB)"
else:
prog_text_base += ")"
self.file_progress_label.setText(prog_text_base)
elif filename and progress_info is None: # Explicit request to clear for a specific file (e.g. download finished/failed)
self.file_progress_label.setText("")
elif not filename and not progress_info: # General clear
self.file_progress_label.setText("")
2025-05-08 19:49:50 +05:30
def update_external_links_setting(self, checked):
2025-05-09 19:03:01 +05:30
is_only_links_mode = self.radio_only_links and self.radio_only_links.isChecked()
2025-05-10 23:59:00 +05:30
is_only_archives_mode = self.radio_only_archives and self.radio_only_archives.isChecked()
2025-05-10 11:07:27 +05:30
if is_only_links_mode or is_only_archives_mode:
2025-05-10 23:59:00 +05:30
if self.external_log_output: self.external_log_output.hide()
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0])
return
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
self.show_external_links = checked
2025-05-08 19:49:50 +05:30
if checked:
2025-05-09 19:03:01 +05:30
if self.external_log_output: self.external_log_output.show()
2025-05-10 23:59:00 +05:30
if self.log_splitter: self.log_splitter.setSizes([self.height() // 2, self.height() // 2])
if self.main_log_output: self.main_log_output.setMinimumHeight(50)
if self.external_log_output: self.external_log_output.setMinimumHeight(50)
self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Enabled\n" + "="*40)
if self.external_log_output:
2025-05-10 11:07:27 +05:30
self.external_log_output.clear()
self.external_log_output.append("🔗 External Links Found:")
2025-05-10 23:59:00 +05:30
self._try_process_next_external_link()
2025-05-08 19:49:50 +05:30
else:
2025-05-09 19:03:01 +05:30
if self.external_log_output: self.external_log_output.hide()
2025-05-10 23:59:00 +05:30
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0])
if self.main_log_output: self.main_log_output.setMinimumHeight(0)
if self.external_log_output: self.external_log_output.setMinimumHeight(0)
if self.external_log_output: self.external_log_output.clear()
self.log_signal.emit("\n" + "="*40 + "\n🔗 External Links Log Disabled\n" + "="*40)
2025-05-10 11:07:27 +05:30
2025-05-09 19:03:01 +05:30
def _handle_filter_mode_change(self, button, checked):
2025-05-10 23:59:00 +05:30
if not button or not checked:
2025-05-09 19:03:01 +05:30
return
2025-05-10 23:59:00 +05:30
filter_mode_text = button.text()
2025-05-09 19:03:01 +05:30
is_only_links = (filter_mode_text == "🔗 Only Links")
2025-05-10 23:59:00 +05:30
is_only_archives = (filter_mode_text == "📦 Only Archives")
2025-05-09 19:03:01 +05:30
if self.link_search_input: self.link_search_input.setVisible(is_only_links)
if self.link_search_button: self.link_search_button.setVisible(is_only_links)
if self.export_links_button:
self.export_links_button.setVisible(is_only_links)
2025-05-10 11:07:27 +05:30
self.export_links_button.setEnabled(is_only_links and bool(self.extracted_links_cache))
2025-05-10 23:59:00 +05:30
if not is_only_links and self.link_search_input: self.link_search_input.clear()
2025-05-10 11:07:27 +05:30
file_download_mode_active = not is_only_links
if self.dir_input: self.dir_input.setEnabled(file_download_mode_active)
if self.dir_button: self.dir_button.setEnabled(file_download_mode_active)
if self.use_subfolders_checkbox: self.use_subfolders_checkbox.setEnabled(file_download_mode_active)
if self.skip_words_input: self.skip_words_input.setEnabled(file_download_mode_active)
if self.skip_scope_toggle_button: self.skip_scope_toggle_button.setEnabled(file_download_mode_active)
2025-05-12 10:54:31 +05:30
if hasattr(self, 'remove_from_filename_input'): self.remove_from_filename_input.setEnabled(file_download_mode_active)
2025-05-10 11:07:27 +05:30
if self.skip_zip_checkbox:
can_skip_zip = not is_only_links and not is_only_archives
self.skip_zip_checkbox.setEnabled(can_skip_zip)
if is_only_archives:
2025-05-10 23:59:00 +05:30
self.skip_zip_checkbox.setChecked(False)
2025-05-10 11:07:27 +05:30
if self.skip_rar_checkbox:
can_skip_rar = not is_only_links and not is_only_archives
self.skip_rar_checkbox.setEnabled(can_skip_rar)
if is_only_archives:
2025-05-10 23:59:00 +05:30
self.skip_rar_checkbox.setChecked(False)
2025-05-10 11:07:27 +05:30
other_file_proc_enabled = not is_only_links and not is_only_archives
if self.download_thumbnails_checkbox: self.download_thumbnails_checkbox.setEnabled(other_file_proc_enabled)
if self.compress_images_checkbox: self.compress_images_checkbox.setEnabled(other_file_proc_enabled)
if self.external_links_checkbox:
can_show_external_log_option = not is_only_links and not is_only_archives
self.external_links_checkbox.setEnabled(can_show_external_log_option)
2025-05-10 23:59:00 +05:30
if not can_show_external_log_option:
self.external_links_checkbox.setChecked(False)
if is_only_links:
self.progress_log_label.setText("📜 Extracted Links Log:")
if self.external_log_output: self.external_log_output.hide()
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0])
if self.main_log_output: self.main_log_output.clear(); self.main_log_output.setMinimumHeight(0)
if self.external_log_output: self.external_log_output.clear(); self.external_log_output.setMinimumHeight(0)
self.log_signal.emit("="*20 + " Mode changed to: Only Links " + "="*20)
self._filter_links_log()
self._try_process_next_external_link()
elif is_only_archives:
self.progress_log_label.setText("📜 Progress Log (Archives Only):")
if self.external_log_output: self.external_log_output.hide()
if self.log_splitter: self.log_splitter.setSizes([self.height(), 0])
if self.main_log_output: self.main_log_output.clear()
self.log_signal.emit("="*20 + " Mode changed to: Only Archives " + "="*20)
else:
self.progress_log_label.setText("📜 Progress Log:")
2025-05-10 11:07:27 +05:30
self.update_external_links_setting(self.external_links_checkbox.isChecked() if self.external_links_checkbox else False)
2025-05-10 23:59:00 +05:30
self.log_signal.emit(f"="*20 + f" Mode changed to: {filter_mode_text} " + "="*20)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
subfolders_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
manga_on = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
enable_character_filter_related_widgets = file_download_mode_active and (subfolders_on or manga_on)
if self.character_input:
self.character_input.setEnabled(enable_character_filter_related_widgets)
if not enable_character_filter_related_widgets:
self.character_input.clear()
if self.char_filter_scope_toggle_button:
self.char_filter_scope_toggle_button.setEnabled(enable_character_filter_related_widgets)
self.update_ui_for_subfolders(subfolders_on)
2025-05-10 11:07:27 +05:30
self.update_custom_folder_visibility()
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
def _filter_links_log(self):
2025-05-10 23:59:00 +05:30
if not (self.radio_only_links and self.radio_only_links.isChecked()): return
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
search_term = self.link_search_input.text().lower().strip() if self.link_search_input else ""
2025-05-10 23:59:00 +05:30
self.main_log_output.clear()
current_title_for_display = None
separator = "-" * 45
2025-05-09 19:03:01 +05:30
for post_title, link_text, link_url, platform in self.extracted_links_cache:
matches_search = (
2025-05-10 23:59:00 +05:30
not search_term or
2025-05-09 19:03:01 +05:30
search_term in link_text.lower() or
search_term in link_url.lower() or
search_term in platform.lower()
)
2025-05-10 23:59:00 +05:30
if matches_search:
if post_title != current_title_for_display:
self.main_log_output.insertHtml("<br>" + separator + "<br>")
title_html = f'<b style="color: #87CEEB;">{post_title}</b><br>'
self.main_log_output.insertHtml(title_html)
current_title_for_display = post_title
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
max_link_text_len = 35
2025-05-09 19:03:01 +05:30
display_text = link_text[:max_link_text_len].strip() + "..." if len(link_text) > max_link_text_len else link_text
formatted_link_info = f"{display_text} - {link_url} - {platform}"
2025-05-10 23:59:00 +05:30
self.main_log_output.append(formatted_link_info)
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
if self.main_log_output.toPlainText().strip():
2025-05-10 11:07:27 +05:30
self.main_log_output.append("")
2025-05-10 23:59:00 +05:30
self.main_log_output.verticalScrollBar().setValue(0)
2025-05-09 19:03:01 +05:30
def _export_links_to_file(self):
if not (self.radio_only_links and self.radio_only_links.isChecked()):
QMessageBox.information(self, "Export Links", "Link export is only available in 'Only Links' mode.")
return
if not self.extracted_links_cache:
QMessageBox.information(self, "Export Links", "No links have been extracted yet.")
return
default_filename = "extracted_links.txt"
filepath, _ = QFileDialog.getSaveFileName(self, "Save Links", default_filename, "Text Files (*.txt);;All Files (*)")
2025-05-10 23:59:00 +05:30
if filepath:
2025-05-09 19:03:01 +05:30
try:
with open(filepath, 'w', encoding='utf-8') as f:
2025-05-10 23:59:00 +05:30
current_title_for_export = None
separator = "-" * 60 + "\n"
2025-05-09 19:03:01 +05:30
for post_title, link_text, link_url, platform in self.extracted_links_cache:
2025-05-10 23:59:00 +05:30
if post_title != current_title_for_export:
if current_title_for_export is not None:
2025-05-09 19:03:01 +05:30
f.write("\n" + separator + "\n")
2025-05-10 23:59:00 +05:30
f.write(f"Post Title: {post_title}\n\n")
current_title_for_export = post_title
2025-05-09 19:03:01 +05:30
f.write(f" {link_text} - {link_url} - {platform}\n")
self.log_signal.emit(f"✅ Links successfully exported to: {filepath}")
QMessageBox.information(self, "Export Successful", f"Links exported to:\n{filepath}")
except Exception as e:
self.log_signal.emit(f"❌ Error exporting links: {e}")
QMessageBox.critical(self, "Export Error", f"Could not export links: {e}")
2025-05-08 19:49:50 +05:30
2025-05-05 19:35:24 +05:30
def get_filter_mode(self):
2025-05-09 19:03:01 +05:30
if self.radio_only_links and self.radio_only_links.isChecked():
2025-05-10 11:07:27 +05:30
return 'all'
elif self.radio_images.isChecked():
return 'image'
elif self.radio_videos.isChecked():
return 'video'
2025-05-10 23:59:00 +05:30
elif self.radio_only_archives and self.radio_only_archives.isChecked():
2025-05-10 11:07:27 +05:30
return 'archive'
2025-05-10 23:59:00 +05:30
elif self.radio_all.isChecked():
2025-05-10 11:07:27 +05:30
return 'all'
2025-05-10 23:59:00 +05:30
return 'all'
2025-05-10 11:07:27 +05:30
def get_skip_words_scope(self):
return self.skip_words_scope
def _update_skip_scope_button_text(self):
2025-05-10 23:59:00 +05:30
if self.skip_scope_toggle_button:
2025-05-10 11:07:27 +05:30
if self.skip_words_scope == SKIP_SCOPE_FILES:
self.skip_scope_toggle_button.setText("Scope: Files")
elif self.skip_words_scope == SKIP_SCOPE_POSTS:
self.skip_scope_toggle_button.setText("Scope: Posts")
elif self.skip_words_scope == SKIP_SCOPE_BOTH:
self.skip_scope_toggle_button.setText("Scope: Both")
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
self.skip_scope_toggle_button.setText("Scope: Unknown")
def _cycle_skip_scope(self):
if self.skip_words_scope == SKIP_SCOPE_FILES:
self.skip_words_scope = SKIP_SCOPE_POSTS
elif self.skip_words_scope == SKIP_SCOPE_POSTS:
self.skip_words_scope = SKIP_SCOPE_BOTH
elif self.skip_words_scope == SKIP_SCOPE_BOTH:
self.skip_words_scope = SKIP_SCOPE_FILES
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
self.skip_words_scope = SKIP_SCOPE_FILES
2025-05-10 23:59:00 +05:30
self._update_skip_scope_button_text()
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
self.log_signal.emit(f" Skip words scope changed to: '{self.skip_words_scope}'")
def get_char_filter_scope(self):
return self.char_filter_scope
def _update_char_filter_scope_button_text(self):
if self.char_filter_scope_toggle_button:
if self.char_filter_scope == CHAR_SCOPE_FILES:
self.char_filter_scope_toggle_button.setText("Filter: Files")
elif self.char_filter_scope == CHAR_SCOPE_TITLE:
self.char_filter_scope_toggle_button.setText("Filter: Title")
elif self.char_filter_scope == CHAR_SCOPE_BOTH:
self.char_filter_scope_toggle_button.setText("Filter: Both")
else:
self.char_filter_scope_toggle_button.setText("Filter: Unknown")
def _cycle_char_filter_scope(self):
if self.char_filter_scope == CHAR_SCOPE_FILES:
self.char_filter_scope = CHAR_SCOPE_TITLE
elif self.char_filter_scope == CHAR_SCOPE_TITLE:
self.char_filter_scope = CHAR_SCOPE_BOTH
elif self.char_filter_scope == CHAR_SCOPE_BOTH:
self.char_filter_scope = CHAR_SCOPE_FILES
else:
self.char_filter_scope = CHAR_SCOPE_FILES
self._update_char_filter_scope_button_text()
self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope)
self.log_signal.emit(f" Character filter scope changed to: '{self.char_filter_scope}'")
2025-05-10 11:07:27 +05:30
2025-05-05 19:35:24 +05:30
def add_new_character(self):
2025-05-10 23:59:00 +05:30
global KNOWN_NAMES, clean_folder_name
name_to_add = self.new_char_input.text().strip()
if not name_to_add:
QMessageBox.warning(self, "Input Error", "Name cannot be empty."); return False
name_lower = name_to_add.lower()
2025-05-10 11:07:27 +05:30
if any(existing.lower() == name_lower for existing in KNOWN_NAMES):
QMessageBox.warning(self, "Duplicate Name", f"The name '{name_to_add}' (case-insensitive) already exists."); return False
similar_names_details = []
2025-05-08 19:49:50 +05:30
for existing_name in KNOWN_NAMES:
existing_name_lower = existing_name.lower()
2025-05-10 11:07:27 +05:30
if name_lower != existing_name_lower and (name_lower in existing_name_lower or existing_name_lower in name_lower):
2025-05-10 23:59:00 +05:30
similar_names_details.append((name_to_add, existing_name))
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
if similar_names_details:
2025-05-08 19:49:50 +05:30
first_similar_new, first_similar_existing = similar_names_details[0]
2025-05-10 11:07:27 +05:30
shorter, longer = sorted([first_similar_new, first_similar_existing], key=len)
2025-05-09 19:03:01 +05:30
2025-05-08 19:49:50 +05:30
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"
2025-05-10 11:07:27 +05:30
f"This could lead to files being grouped into less specific folders (e.g., under '{clean_folder_name(shorter)}' instead of a more specific '{clean_folder_name(longer)}').\n\n"
2025-05-08 19:49:50 +05:30
"Do you want to change the name you are adding, or proceed anyway?"
)
2025-05-10 23:59:00 +05:30
change_button = msg_box.addButton("Change Name", QMessageBox.RejectRole)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
msg_box.setDefaultButton(proceed_button)
msg_box.setEscapeButton(change_button)
2025-05-08 19:49:50 +05:30
msg_box.exec_()
2025-05-10 23:59:00 +05:30
if msg_box.clickedButton() == change_button:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f" User chose to change '{first_similar_new}' due to similarity with '{first_similar_existing}'.")
2025-05-10 23:59:00 +05:30
return False
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"⚠️ User proceeded with adding '{first_similar_new}' despite similarity with '{first_similar_existing}'.")
2025-05-08 19:49:50 +05:30
KNOWN_NAMES.append(name_to_add)
2025-05-10 23:59:00 +05:30
KNOWN_NAMES.sort(key=str.lower)
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
self.character_list.clear()
self.character_list.addItems(KNOWN_NAMES)
2025-05-10 23:59:00 +05:30
self.filter_character_list(self.character_search_input.text())
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"✅ Added '{name_to_add}' to known names list.")
2025-05-10 23:59:00 +05:30
self.new_char_input.clear()
self.save_known_names()
return True
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
def delete_selected_character(self):
2025-05-10 23:59:00 +05:30
global KNOWN_NAMES
selected_items = self.character_list.selectedItems()
if not selected_items:
2025-05-10 11:07:27 +05:30
QMessageBox.warning(self, "Selection Error", "Please select one or more names to delete."); return
2025-05-10 23:59:00 +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-10 23:59:00 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
2025-05-05 19:35:24 +05:30
if confirm == QMessageBox.Yes:
2025-05-08 19:49:50 +05:30
original_count = len(KNOWN_NAMES)
2025-05-10 11:07:27 +05:30
KNOWN_NAMES[:] = [n for n in KNOWN_NAMES if n not in names_to_remove]
2025-05-08 19:49:50 +05:30
removed_count = original_count - len(KNOWN_NAMES)
2025-05-06 22:08:27 +05:30
2025-05-10 23:59:00 +05:30
if removed_count > 0:
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"🗑️ Removed {removed_count} name(s).")
2025-05-10 11:07:27 +05:30
self.character_list.clear()
2025-05-08 19:49:50 +05:30
self.character_list.addItems(KNOWN_NAMES)
2025-05-10 23:59:00 +05:30
self.filter_character_list(self.character_search_input.text())
self.save_known_names()
else:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(" No names were removed (they might not have been in the list).")
2025-05-06 22:08:27 +05:30
def update_custom_folder_visibility(self, url_text=None):
2025-05-10 23:59:00 +05:30
if url_text is None:
url_text = self.link_input.text()
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
_, _, post_id = extract_post_info(url_text.strip())
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
is_single_post_url = bool(post_id)
2025-05-10 11:07:27 +05:30
subfolders_enabled = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
not_only_links_or_archives_mode = not (
(self.radio_only_links and self.radio_only_links.isChecked()) or
(self.radio_only_archives and self.radio_only_archives.isChecked())
)
2025-05-06 22:08:27 +05:30
2025-05-10 11:07:27 +05:30
should_show_custom_folder = is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode
2025-05-10 23:59:00 +05:30
if self.custom_folder_widget:
self.custom_folder_widget.setVisible(should_show_custom_folder)
2025-05-10 11:07:27 +05:30
if not (self.custom_folder_widget and self.custom_folder_widget.isVisible()):
if self.custom_folder_input: self.custom_folder_input.clear()
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
def update_ui_for_subfolders(self, checked):
is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
is_only_archives = self.radio_only_archives and self.radio_only_archives.isChecked()
2025-05-10 23:59:00 +05:30
if self.use_subfolder_per_post_checkbox:
self.use_subfolder_per_post_checkbox.setEnabled(not is_only_links and not is_only_archives)
enable_character_filter_related_widgets = checked and not is_only_links and not is_only_archives
if self.character_filter_widget:
self.character_filter_widget.setVisible(enable_character_filter_related_widgets)
if not self.character_filter_widget.isVisible():
if self.character_input: self.character_input.clear()
if self.char_filter_scope_toggle_button: self.char_filter_scope_toggle_button.setEnabled(False)
else:
if self.char_filter_scope_toggle_button: self.char_filter_scope_toggle_button.setEnabled(True)
2025-05-10 11:07:27 +05:30
self.update_custom_folder_visibility()
2025-05-08 19:49:50 +05:30
def update_page_range_enabled_state(self):
2025-05-10 11:07:27 +05:30
url_text = self.link_input.text().strip() if self.link_input else ""
2025-05-10 23:59:00 +05:30
_, _, post_id = extract_post_info(url_text)
2025-05-09 19:03:01 +05:30
2025-05-10 23:59:00 +05:30
is_creator_feed = not post_id if url_text else False
2025-05-08 19:49:50 +05:30
manga_mode_active = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
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)
2025-05-10 11:07:27 +05:30
if not enable_page_range:
if self.start_page_input: self.start_page_input.clear()
if self.end_page_input: self.end_page_input.clear()
def _update_manga_filename_style_button_text(self):
2025-05-10 23:59:00 +05:30
if self.manga_rename_toggle_button:
2025-05-10 11:07:27 +05:30
if self.manga_filename_style == STYLE_POST_TITLE:
self.manga_rename_toggle_button.setText("Name: Post Title")
self.manga_rename_toggle_button.setToolTip(
"Manga files: First file named by post title. Subsequent files in same post keep original names.\n"
"Click to change to original file names for all files."
)
elif self.manga_filename_style == STYLE_ORIGINAL_NAME:
self.manga_rename_toggle_button.setText("Name: Original File")
self.manga_rename_toggle_button.setToolTip(
"Manga files will keep their original names as provided by the site (e.g., 001.jpg, page_01.png).\n"
"Click to change to post title based naming for the first file."
)
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
self.manga_rename_toggle_button.setText("Name: Unknown Style")
self.manga_rename_toggle_button.setToolTip("Manga filename style is in an unknown state.")
def _toggle_manga_filename_style(self):
current_style = self.manga_filename_style
new_style = ""
2025-05-10 23:59:00 +05:30
if current_style == STYLE_POST_TITLE:
2025-05-10 11:07:27 +05:30
new_style = STYLE_ORIGINAL_NAME
reply = QMessageBox.information(self, "Manga Filename Preference",
"Using 'Name: Post Title' (first file by title, others original) is recommended for Manga Mode.\n\n"
"Using 'Name: Original File' for all files might lead to less organized downloads if original names are inconsistent or non-sequential.\n\n"
"Proceed with using 'Name: Original File' for all files?",
2025-05-10 23:59:00 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply == QMessageBox.No:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(" Manga filename style change to 'Original File' cancelled by user.")
2025-05-10 23:59:00 +05:30
return
elif current_style == STYLE_ORIGINAL_NAME:
2025-05-10 11:07:27 +05:30
new_style = STYLE_POST_TITLE
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"⚠️ Unknown current manga filename style: {current_style}. Resetting to default ('{STYLE_POST_TITLE}').")
new_style = STYLE_POST_TITLE
2025-05-10 23:59:00 +05:30
self.manga_filename_style = new_style
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.sync()
self._update_manga_filename_style_button_text()
self.log_signal.emit(f" Manga filename style changed to: '{self.manga_filename_style}'")
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
def update_ui_for_manga_mode(self, checked):
2025-05-10 11:07:27 +05:30
url_text = self.link_input.text().strip() if self.link_input else ""
2025-05-10 23:59:00 +05:30
_, _, post_id = extract_post_info(url_text)
2025-05-10 11:07:27 +05:30
is_creator_feed = not post_id if url_text else False
2025-05-10 23:59:00 +05:30
if self.manga_mode_checkbox:
2025-05-10 11:07:27 +05:30
self.manga_mode_checkbox.setEnabled(is_creator_feed)
2025-05-10 23:59:00 +05:30
if not is_creator_feed and self.manga_mode_checkbox.isChecked():
2025-05-10 11:07:27 +05:30
self.manga_mode_checkbox.setChecked(False)
checked = self.manga_mode_checkbox.isChecked()
2025-05-10 23:59:00 +05:30
manga_mode_effectively_on = is_creator_feed and checked
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
if self.manga_rename_toggle_button:
2025-05-10 11:07:27 +05:30
self.manga_rename_toggle_button.setVisible(manga_mode_effectively_on)
2025-05-12 10:54:31 +05:30
if hasattr(self, 'duplicate_mode_toggle_button'):
self.duplicate_mode_toggle_button.setVisible(not manga_mode_effectively_on) # Hidden in Manga Mode
2025-05-10 11:07:27 +05:30
if manga_mode_effectively_on:
if self.page_range_label: self.page_range_label.setEnabled(False)
if self.start_page_input: self.start_page_input.setEnabled(False); self.start_page_input.clear()
if self.to_label: self.to_label.setEnabled(False)
if self.end_page_input: self.end_page_input.setEnabled(False); self.end_page_input.clear()
2025-05-10 23:59:00 +05:30
else:
2025-05-08 19:49:50 +05:30
self.update_page_range_enabled_state()
2025-05-10 23:59:00 +05:30
file_download_mode_active = not (self.radio_only_links and self.radio_only_links.isChecked())
subfolders_on = self.use_subfolders_checkbox.isChecked() if self.use_subfolders_checkbox else False
enable_char_filter_widgets = file_download_mode_active and (subfolders_on or manga_mode_effectively_on)
if self.character_input:
self.character_input.setEnabled(enable_char_filter_widgets)
if not enable_char_filter_widgets: self.character_input.clear()
if self.char_filter_scope_toggle_button:
self.char_filter_scope_toggle_button.setEnabled(enable_char_filter_widgets)
2025-05-08 19:49:50 +05:30
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-10 23:59:00 +05:30
search_text_lower = search_text.lower()
for i in range(self.character_list.count()):
2025-05-06 22:08:27 +05:30
item = self.character_list.item(i)
2025-05-08 19:49:50 +05:30
item.setHidden(search_text_lower not in item.text().lower())
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
def update_multithreading_label(self, text):
if self.use_multithreading_checkbox.isChecked():
2025-05-08 22:13:12 +05:30
try:
2025-05-10 23:59:00 +05:30
num_threads_val = int(text)
2025-05-10 11:07:27 +05:30
if num_threads_val > 0 : self.use_multithreading_checkbox.setText(f"Use Multithreading ({num_threads_val} Threads)")
2025-05-10 23:59:00 +05:30
else: self.use_multithreading_checkbox.setText("Use Multithreading (Invalid: >0)")
except ValueError:
2025-05-08 22:13:12 +05:30
self.use_multithreading_checkbox.setText("Use Multithreading (Invalid Input)")
2025-05-10 23:59:00 +05:30
else:
2025-05-08 22:13:12 +05:30
self.use_multithreading_checkbox.setText("Use Multithreading (1 Thread)")
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
def _handle_multithreading_toggle(self, checked):
if not checked:
self.thread_count_input.setEnabled(False)
self.thread_count_label.setEnabled(False)
2025-05-10 11:07:27 +05:30
self.use_multithreading_checkbox.setText("Use Multithreading (1 Thread)")
2025-05-10 23:59:00 +05:30
else:
self.thread_count_input.setEnabled(True)
self.thread_count_label.setEnabled(True)
2025-05-08 22:13:12 +05:30
self.update_multithreading_label(self.thread_count_input.text())
2025-05-06 22:08:27 +05:30
def update_progress_display(self, total_posts, processed_posts):
2025-05-10 23:59:00 +05:30
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}%)")
2025-05-10 23:59:00 +05:30
elif processed_posts > 0 :
2025-05-06 22:08:27 +05:30
self.progress_label.setText(f"Progress: Processing post {processed_posts}...")
2025-05-10 23:59:00 +05:30
else:
2025-05-06 22:08:27 +05:30
self.progress_label.setText("Progress: Starting...")
2025-05-10 11:07:27 +05:30
if total_posts > 0 or processed_posts > 0 :
2025-05-10 23:59:00 +05:30
self.file_progress_label.setText("")
2025-05-08 19:49:50 +05:30
2025-05-05 19:35:24 +05:30
def start_download(self):
2025-05-10 11:07:27 +05:30
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER
2025-05-10 23:59:00 +05:30
if self._is_download_active():
2025-05-10 11:07:27 +05:30
QMessageBox.warning(self, "Busy", "A download is already running."); return
2025-05-05 19:35:24 +05:30
api_url = self.link_input.text().strip()
output_dir = self.dir_input.text().strip()
2025-05-10 11:07:27 +05:30
2025-05-05 19:35:24 +05:30
use_subfolders = self.use_subfolders_checkbox.isChecked()
2025-05-10 23:59:00 +05:30
use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked()
2025-05-06 22:08:27 +05:30
compress_images = self.compress_images_checkbox.isChecked()
download_thumbnails = self.download_thumbnails_checkbox.isChecked()
2025-05-10 11:07:27 +05:30
use_multithreading_enabled_by_checkbox = self.use_multithreading_checkbox.isChecked()
2025-05-10 23:59:00 +05:30
try:
2025-05-10 11:07:27 +05:30
num_threads_from_gui = int(self.thread_count_input.text().strip())
2025-05-10 23:59:00 +05:30
if num_threads_from_gui < 1: num_threads_from_gui = 1
except ValueError:
2025-05-10 11:07:27 +05:30
QMessageBox.critical(self, "Thread Count Error", "Invalid number of threads. Please enter a positive number.")
2025-05-10 23:59:00 +05:30
self.set_ui_enabled(True)
2025-05-10 11:07:27 +05:30
return
2025-05-10 23:59:00 +05:30
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()]
2025-05-10 23:59:00 +05:30
current_skip_words_scope = self.get_skip_words_scope()
2025-05-12 10:54:31 +05:30
raw_remove_filename_words = self.remove_from_filename_input.text().strip() if hasattr(self, 'remove_from_filename_input') else ""
effective_duplicate_file_mode = self.duplicate_file_mode # Start with user's choice
allow_multipart = self.allow_multipart_download_setting # Use the internal setting
remove_from_filename_words_list = [word.strip() for word in raw_remove_filename_words.split(',') if word.strip()]
2025-05-10 23:59:00 +05:30
current_char_filter_scope = self.get_char_filter_scope()
2025-05-08 19:49:50 +05:30
manga_mode_is_checked = self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False
2025-05-10 11:07:27 +05:30
2025-05-09 19:03:01 +05:30
extract_links_only = (self.radio_only_links and self.radio_only_links.isChecked())
2025-05-10 23:59:00 +05:30
backend_filter_mode = self.get_filter_mode()
2025-05-09 19:03:01 +05:30
user_selected_filter_text = self.radio_group.checkedButton().text() if self.radio_group.checkedButton() else "All"
2025-05-10 11:07:27 +05:30
if backend_filter_mode == 'archive':
effective_skip_zip = False
effective_skip_rar = False
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
effective_skip_zip = self.skip_zip_checkbox.isChecked()
effective_skip_rar = self.skip_rar_checkbox.isChecked()
if not api_url: QMessageBox.critical(self, "Input Error", "URL is required."); return
2025-05-09 19:03:01 +05:30
if not extract_links_only and not output_dir:
QMessageBox.critical(self, "Input Error", "Download Directory is required when not in 'Only Links' mode."); return
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
2025-05-08 19:49:50 +05:30
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format."); return
2025-05-06 22:08:27 +05:30
2025-05-09 19:03:01 +05:30
if not extract_links_only and not os.path.isdir(output_dir):
2025-05-08 19:49:50 +05:30
reply = QMessageBox.question(self, "Create Directory?",
f"The directory '{output_dir}' does not exist.\nCreate it now?",
2025-05-10 23:59:00 +05:30
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
2025-05-08 19:49:50 +05:30
if reply == QMessageBox.Yes:
2025-05-10 11:07:27 +05:30
try: os.makedirs(output_dir, exist_ok=True); self.log_signal.emit(f" Created directory: {output_dir}")
except Exception as e: QMessageBox.critical(self, "Directory Error", f"Could not create directory: {e}"); return
else: self.log_signal.emit("❌ Download cancelled: Output directory does not exist and was not created."); return
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
if compress_images and Image is None:
2025-05-08 19:49:50 +05:30
QMessageBox.warning(self, "Missing Dependency", "Pillow library (for image compression) not found. Compression will be disabled.")
2025-05-10 23:59:00 +05:30
compress_images = False; self.compress_images_checkbox.setChecked(False)
2025-05-08 19:49:50 +05:30
2025-05-09 19:03:01 +05:30
manga_mode = manga_mode_is_checked and not post_id_from_url
2025-05-08 19:49:50 +05:30
start_page_str, end_page_str = self.start_page_input.text().strip(), self.end_page_input.text().strip()
2025-05-10 23:59:00 +05:30
start_page, end_page = None, None
is_creator_feed = bool(not post_id_from_url)
if is_creator_feed and not manga_mode:
try:
2025-05-08 19:49:50 +05:30
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.")
2025-05-10 11:07:27 +05:30
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
2025-05-10 23:59:00 +05:30
elif manga_mode:
2025-05-10 11:07:27 +05:30
start_page, end_page = None, None
2025-05-10 23:59:00 +05:30
2025-05-12 10:54:31 +05:30
# effective_duplicate_file_mode will be self.duplicate_file_mode (UI button's state).
# Manga Mode specific duplicate handling is now managed entirely within downloader_utils.py
2025-05-10 11:07:27 +05:30
self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None
2025-05-10 23:59:00 +05:30
self.all_kept_original_filenames = []
2025-05-08 19:49:50 +05:30
raw_character_filters_text = self.character_input.text().strip()
2025-05-10 23:59:00 +05:30
2025-05-12 10:54:31 +05:30
# --- New parsing logic for character filters ---
parsed_character_filter_objects = []
if raw_character_filters_text:
raw_parts = []
current_part_buffer = ""
in_group_parsing = False
for char_token in raw_character_filters_text:
if char_token == '(':
in_group_parsing = True
current_part_buffer += char_token
elif char_token == ')':
in_group_parsing = False
current_part_buffer += char_token
elif char_token == ',' and not in_group_parsing:
if current_part_buffer.strip(): raw_parts.append(current_part_buffer.strip())
current_part_buffer = ""
else:
current_part_buffer += char_token
if current_part_buffer.strip(): raw_parts.append(current_part_buffer.strip())
for part_str in raw_parts:
part_str = part_str.strip()
if not part_str: continue
if part_str.startswith("(") and part_str.endswith(")"):
group_content_str = part_str[1:-1].strip()
aliases_in_group = [alias.strip() for alias in group_content_str.split(',') if alias.strip()]
if aliases_in_group:
group_folder_name = " ".join(aliases_in_group)
parsed_character_filter_objects.append({
"name": group_folder_name, # This is the primary/folder name
"is_group": True,
"aliases": aliases_in_group # These are for matching
})
else:
parsed_character_filter_objects.append({
"name": part_str, # Folder name and matching name are the same
"is_group": False,
"aliases": [part_str]
})
# --- End new parsing logic ---
filter_character_list_to_pass = None
2025-05-10 23:59:00 +05:30
needs_folder_naming_validation = (use_subfolders or manga_mode) and not extract_links_only
2025-05-12 10:54:31 +05:30
if parsed_character_filter_objects and not extract_links_only :
self.log_signal.emit(f" Validating character filters: {', '.join(item['name'] + (' (Group: ' + '/'.join(item['aliases']) + ')' if item['is_group'] else '') for item in parsed_character_filter_objects)}")
2025-05-10 23:59:00 +05:30
valid_filters_for_backend = []
user_cancelled_validation = False
2025-05-08 19:49:50 +05:30
2025-05-12 10:54:31 +05:30
for filter_item_obj in parsed_character_filter_objects:
item_primary_name = filter_item_obj["name"]
cleaned_name_test = clean_folder_name(item_primary_name)
2025-05-10 23:59:00 +05:30
if needs_folder_naming_validation and not cleaned_name_test:
2025-05-12 10:54:31 +05:30
QMessageBox.warning(self, "Invalid Filter Name for Folder", f"Filter name '{item_primary_name}' is invalid for a folder and will be skipped for folder naming.")
self.log_signal.emit(f"⚠️ Skipping invalid filter for folder naming: '{item_primary_name}'")
continue
# --- New: Check if any alias of a group is already known ---
an_alias_is_already_known = False
if filter_item_obj["is_group"] and needs_folder_naming_validation:
for alias in filter_item_obj["aliases"]:
if any(existing_known.lower() == alias.lower() for existing_known in KNOWN_NAMES):
an_alias_is_already_known = True
self.log_signal.emit(f" Alias '{alias}' (from group '{item_primary_name}') is already in Known Names. Group name '{item_primary_name}' will not be added to Known.txt.")
break
# --- End new check ---
if an_alias_is_already_known:
valid_filters_for_backend.append(filter_item_obj)
2025-05-10 23:59:00 +05:30
continue
2025-05-12 10:54:31 +05:30
# Determine if we should prompt to add the name to the Known.txt list.
# Prompt if:
# - Folder naming validation is relevant (subfolders or manga mode, and not just extracting links)
# - AND Manga Mode is OFF (this is the key change for your request)
# - AND the primary name of the filter isn't already in Known.txt
should_prompt_to_add_to_known_list = (
needs_folder_naming_validation and
not manga_mode and # Do NOT prompt if Manga Mode is ON
item_primary_name.lower() not in {kn.lower() for kn in KNOWN_NAMES}
)
if should_prompt_to_add_to_known_list:
2025-05-10 23:59:00 +05:30
reply = QMessageBox.question(self, "Add to Known List?",
2025-05-12 10:54:31 +05:30
f"Filter name '{item_primary_name}' (used for folder/manga naming) is not in known names list.\nAdd it now?",
2025-05-10 11:07:27 +05:30
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes)
2025-05-10 23:59:00 +05:30
if reply == QMessageBox.Yes:
2025-05-12 10:54:31 +05:30
self.new_char_input.setText(item_primary_name) # Use the primary name for adding
if self.add_new_character():
valid_filters_for_backend.append(filter_item_obj)
2025-05-10 23:59:00 +05:30
elif reply == QMessageBox.Cancel:
user_cancelled_validation = True; break
2025-05-12 10:54:31 +05:30
# If 'No', the filter is not used and not added to Known.txt for this session.
2025-05-10 23:59:00 +05:30
else:
2025-05-12 10:54:31 +05:30
# Add to filters to be used for this session if:
# - Prompting is not needed (e.g., name already known, or not manga_mode but name is known)
# - OR Manga Mode is ON (filter is used without adding to Known.txt)
# - OR extract_links_only is true (folder naming validation is false)
valid_filters_for_backend.append(filter_item_obj)
if manga_mode and needs_folder_naming_validation and item_primary_name.lower() not in {kn.lower() for kn in KNOWN_NAMES}:
self.log_signal.emit(f" Manga Mode: Using filter '{item_primary_name}' for this session without adding to Known Names.")
2025-05-10 23:59:00 +05:30
if user_cancelled_validation: return
if valid_filters_for_backend:
2025-05-08 19:49:50 +05:30
filter_character_list_to_pass = valid_filters_for_backend
2025-05-12 10:54:31 +05:30
self.log_signal.emit(f" Using validated character filters: {', '.join(item['name'] for item in filter_character_list_to_pass)}")
2025-05-10 23:59:00 +05:30
else:
2025-05-12 10:54:31 +05:30
self.log_signal.emit("⚠️ No valid character filters to use for this session.")
elif parsed_character_filter_objects : # If not extract_links_only is false, but filters exist
filter_character_list_to_pass = parsed_character_filter_objects
self.log_signal.emit(f" Character filters provided (folder naming validation may not apply): {', '.join(item['name'] for item in filter_character_list_to_pass)}")
2025-05-10 11:07:27 +05:30
2025-05-09 19:03:01 +05:30
if manga_mode and not filter_character_list_to_pass and not extract_links_only:
2025-05-08 19:49:50 +05:30
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Manga Mode Filter Warning")
msg_box.setText(
2025-05-10 11:07:27 +05:30
"Manga Mode is enabled, but 'Filter by Character(s)' is empty.\n\n"
"For best results (correct file naming and folder organization if subfolders are on), "
"please enter the Manga/Series title into the filter field.\n\n"
"Proceed without a filter (names might be generic, folder might be less specific)?"
2025-05-08 19:49:50 +05:30
)
2025-05-09 19:03:01 +05:30
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel Download", QMessageBox.RejectRole)
2025-05-08 19:49:50 +05:30
msg_box.exec_()
2025-05-10 23:59:00 +05:30
if msg_box.clickedButton() == cancel_button:
2025-05-10 11:07:27 +05:30
self.log_signal.emit("❌ Download cancelled due to Manga Mode filter warning."); return
2025-05-10 23:59:00 +05:30
else:
2025-05-08 19:49:50 +05:30
self.log_signal.emit("⚠️ Proceeding with Manga Mode without a specific title filter.")
2025-05-10 23:59:00 +05:30
custom_folder_name_cleaned = None
2025-05-10 11:07:27 +05:30
if use_subfolders and post_id_from_url and self.custom_folder_widget and self.custom_folder_widget.isVisible() and not extract_links_only:
2025-05-10 23:59:00 +05:30
raw_custom_name = self.custom_folder_input.text().strip()
if raw_custom_name:
cleaned_custom = clean_folder_name(raw_custom_name)
if cleaned_custom: custom_folder_name_cleaned = cleaned_custom
2025-05-10 11:07:27 +05:30
else: self.log_signal.emit(f"⚠️ Invalid custom folder name ignored: '{raw_custom_name}' (resulted in empty string after cleaning).")
2025-05-10 23:59:00 +05:30
self.main_log_output.clear()
if extract_links_only: self.main_log_output.append("🔗 Extracting Links...");
elif backend_filter_mode == 'archive': self.main_log_output.append("📦 Downloading Archives Only...")
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
if self.external_log_output: self.external_log_output.clear()
2025-05-10 11:07:27 +05:30
if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive':
2025-05-09 19:03:01 +05:30
self.external_log_output.append("🔗 External Links Found:")
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
self.file_progress_label.setText(""); self.cancellation_event.clear(); self.active_futures = []
self.total_posts_to_process = self.processed_posts_count = self.download_counter = self.skip_counter = 0
self.progress_label.setText("Progress: Initializing...")
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
effective_num_post_workers = 1
effective_num_file_threads_per_worker = 1
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
if post_id_from_url:
if use_multithreading_enabled_by_checkbox:
2025-05-10 11:07:27 +05:30
effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER))
2025-05-10 23:59:00 +05:30
else:
if use_multithreading_enabled_by_checkbox:
effective_num_post_workers = max(1, min(num_threads_from_gui, MAX_THREADS))
2025-05-10 11:07:27 +05:30
effective_num_file_threads_per_worker = max(1, min(num_threads_from_gui, MAX_FILE_THREADS_PER_POST_OR_WORKER))
log_messages = ["="*40, f"🚀 Starting {'Link Extraction' if extract_links_only else ('Archive Download' if backend_filter_mode == 'archive' else 'Download')} @ {time.strftime('%Y-%m-%d %H:%M:%S')}", f" URL: {api_url}"]
if not extract_links_only: log_messages.append(f" Save Location: {output_dir}")
2025-05-10 23:59:00 +05:30
if post_id_from_url:
2025-05-10 11:07:27 +05:30
log_messages.append(f" Mode: Single Post")
log_messages.append(f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker} concurrent file(s)")
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
log_messages.append(f" Mode: Creator Feed")
log_messages.append(f" Post Processing: {'Multi-threaded (' + str(effective_num_post_workers) + ' workers)' if effective_num_post_workers > 1 else 'Single-threaded (1 worker)'}")
log_messages.append(f" ↳ File Downloads per Worker: Up to {effective_num_file_threads_per_worker} concurrent file(s)")
2025-05-10 23:59:00 +05:30
if is_creator_feed:
2025-05-10 11:07:27 +05:30
if manga_mode: log_messages.append(" Page Range: All (Manga Mode - Oldest Posts Processed First)")
2025-05-10 23:59:00 +05:30
else:
pr_log = "All"
2025-05-10 11:07:27 +05:30
if start_page or end_page:
pr_log = f"{f'From {start_page} ' if start_page else ''}{'to ' if start_page and end_page else ''}{f'{end_page}' if end_page else (f'Up to {end_page}' if end_page else (f'From {start_page}' if start_page else 'Specific Range'))}".strip()
log_messages.append(f" Page Range: {pr_log if pr_log else 'All'}")
2025-05-10 23:59:00 +05:30
if not extract_links_only:
2025-05-09 19:03:01 +05:30
log_messages.append(f" Subfolders: {'Enabled' if use_subfolders else 'Disabled'}")
2025-05-10 23:59:00 +05:30
if use_subfolders:
2025-05-09 19:03:01 +05:30
if custom_folder_name_cleaned: log_messages.append(f" Custom Folder (Post): '{custom_folder_name_cleaned}'")
2025-05-10 23:59:00 +05:30
if filter_character_list_to_pass:
2025-05-12 10:54:31 +05:30
log_messages.append(f" Character Filters: {', '.join(item['name'] for item in filter_character_list_to_pass)}")
2025-05-10 23:59:00 +05:30
log_messages.append(f" ↳ Char Filter Scope: {current_char_filter_scope.capitalize()}")
elif use_subfolders:
log_messages.append(f" Folder Naming: Automatic (based on title/known names)")
2025-05-09 19:03:01 +05:30
log_messages.extend([
f" File Type Filter: {user_selected_filter_text} (Backend processing as: {backend_filter_mode})",
2025-05-10 23:59:00 +05:30
f" Skip Archives: {'.zip' if effective_skip_zip else ''}{', ' if effective_skip_zip and effective_skip_rar else ''}{'.rar' if effective_skip_rar else ''}{'None (Archive Mode)' if backend_filter_mode == 'archive' else ('None' if not (effective_skip_zip or effective_skip_rar) else '')}",
2025-05-09 19:03:01 +05:30
f" Skip Words (posts/files): {', '.join(skip_words_list) if skip_words_list else 'None'}",
2025-05-10 11:07:27 +05:30
f" Skip Words Scope: {current_skip_words_scope.capitalize()}",
2025-05-12 10:54:31 +05:30
f" Remove Words from Filename: {', '.join(remove_from_filename_words_list) if remove_from_filename_words_list else 'None'}",
2025-05-09 19:03:01 +05:30
f" Compress Images: {'Enabled' if compress_images else 'Disabled'}",
2025-05-12 10:54:31 +05:30
f" Thumbnails Only: {'Enabled' if download_thumbnails else 'Disabled'}",
f" Multi-part Download: {'Enabled' if allow_multipart else 'Disabled'}"
2025-05-09 19:03:01 +05:30
])
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
log_messages.append(f" Mode: Extracting Links Only")
log_messages.append(f" Show External Links: {'Enabled' if self.show_external_links and not extract_links_only and backend_filter_mode != 'archive' else 'Disabled'}")
2025-05-10 23:59:00 +05:30
if manga_mode:
2025-05-10 11:07:27 +05:30
log_messages.append(f" Manga Mode (File Renaming by Post Title): Enabled")
log_messages.append(f" ↳ Manga Filename Style: {'Post Title Based' if self.manga_filename_style == STYLE_POST_TITLE else 'Original File Name'}")
2025-05-10 23:59:00 +05:30
if filter_character_list_to_pass:
2025-05-12 10:54:31 +05:30
log_messages.append(f" ↳ Manga Character Filter (for naming/folder): {', '.join(item['name'] for item in filter_character_list_to_pass)}")
2025-05-10 23:59:00 +05:30
log_messages.append(f" ↳ Char Filter Scope (Manga): {current_char_filter_scope.capitalize()}")
2025-05-12 10:54:31 +05:30
log_messages.append(f" ↳ Manga Duplicates: Will be renamed with numeric suffix if names clash (e.g., _1, _2).")
2025-05-10 11:07:27 +05:30
should_use_multithreading_for_posts = use_multithreading_enabled_by_checkbox and not post_id_from_url
log_messages.append(f" Threading: {'Multi-threaded (posts)' if should_use_multithreading_for_posts else 'Single-threaded (posts)'}")
2025-05-10 23:59:00 +05:30
if should_use_multithreading_for_posts:
2025-05-10 11:07:27 +05:30
log_messages.append(f" Number of Post Worker Threads: {effective_num_post_workers}")
2025-05-10 23:59:00 +05:30
log_messages.append("="*40)
for msg in log_messages: self.log_signal.emit(msg)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
self.set_ui_enabled(False)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
unwanted_keywords_for_folders = {'spicy', 'hd', 'nsfw', '4k', 'preview', 'teaser', 'clip'}
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
args_template = {
'api_url_input': api_url,
2025-05-10 23:59:00 +05:30
'download_root': output_dir,
'output_dir': output_dir,
'known_names': list(KNOWN_NAMES),
'known_names_copy': list(KNOWN_NAMES),
2025-05-08 19:49:50 +05:30
'filter_character_list': filter_character_list_to_pass,
2025-05-10 23:59:00 +05:30
'filter_mode': backend_filter_mode,
'skip_zip': effective_skip_zip,
'skip_rar': effective_skip_rar,
2025-05-10 11:07:27 +05:30
'use_subfolders': use_subfolders,
'use_post_subfolders': use_post_subfolders,
'compress_images': compress_images,
'download_thumbnails': download_thumbnails,
2025-05-10 23:59:00 +05:30
'service': service,
'user_id': user_id,
'downloaded_files': self.downloaded_files,
'downloaded_files_lock': self.downloaded_files_lock,
'downloaded_file_hashes': self.downloaded_file_hashes,
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
2025-05-08 19:49:50 +05:30
'skip_words_list': skip_words_list,
2025-05-10 11:07:27 +05:30
'skip_words_scope': current_skip_words_scope,
2025-05-12 10:54:31 +05:30
'remove_from_filename_words_list': remove_from_filename_words_list,
2025-05-10 23:59:00 +05:30
'char_filter_scope': current_char_filter_scope,
'show_external_links': self.show_external_links,
'extract_links_only': extract_links_only,
'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,
'signals': self.worker_signals,
'manga_filename_style': self.manga_filename_style,
2025-05-12 10:54:31 +05:30
'num_file_threads_for_worker': effective_num_file_threads_per_worker,
'allow_multipart_download': allow_multipart, # Corrected from previous thought
'duplicate_file_mode': effective_duplicate_file_mode # Pass the potentially overridden mode
2025-05-08 19:49:50 +05:30
}
try:
2025-05-10 23:59:00 +05:30
if should_use_multithreading_for_posts:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f" Initializing multi-threaded {'link extraction' if extract_links_only else 'download'} with {effective_num_post_workers} post workers...")
self.start_multi_threaded_download(num_post_workers=effective_num_post_workers, **args_template)
2025-05-10 23:59:00 +05:30
else:
2025-05-09 19:03:01 +05:30
self.log_signal.emit(f" Initializing single-threaded {'link extraction' if extract_links_only else 'download'}...")
2025-05-08 19:49:50 +05:30
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',
2025-05-12 10:54:31 +05:30
'downloaded_files', 'downloaded_file_hashes', 'remove_from_filename_words_list',
2025-05-10 23:59:00 +05:30
'downloaded_files_lock', 'downloaded_file_hashes_lock',
'skip_words_list', 'skip_words_scope', 'char_filter_scope',
'show_external_links', 'extract_links_only',
'num_file_threads_for_worker',
'skip_current_file_flag',
2025-05-08 19:49:50 +05:30
'start_page', 'end_page', 'target_post_id_from_initial_url',
2025-05-12 10:54:31 +05:30
'manga_mode_active', 'unwanted_keywords', 'manga_filename_style', 'duplicate_file_mode',
'allow_multipart_download'
2025-05-08 19:49:50 +05:30
]
2025-05-10 23:59:00 +05:30
args_template['skip_current_file_flag'] = None
2025-05-10 11:07:27 +05:30
single_thread_args = {key: args_template[key] for key in dt_expected_keys if key in args_template}
2025-05-10 23:59:00 +05:30
self.start_single_threaded_download(**single_thread_args)
except Exception as e:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"❌ CRITICAL ERROR preparing download: {e}\n{traceback.format_exc()}")
2025-05-09 19:03:01 +05:30
QMessageBox.critical(self, "Start Error", f"Failed to start process:\n{e}")
2025-05-10 23:59:00 +05:30
self.download_finished(0,0,False, [])
2025-05-06 22:08:27 +05:30
def start_single_threaded_download(self, **kwargs):
2025-05-10 23:59:00 +05:30
global BackendDownloadThread
2025-05-06 22:08:27 +05:30
try:
2025-05-10 23:59:00 +05:30
self.download_thread = BackendDownloadThread(**kwargs)
2025-05-10 11:07:27 +05:30
if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.connect(self.handle_main_log)
if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.connect(self.add_character_prompt_signal)
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.connect(self.download_finished)
if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.connect(self.download_thread.receive_add_character_result)
if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.connect(self.handle_external_link_signal)
if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.connect(self.update_file_progress_display)
2025-05-10 23:59:00 +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-10 23:59:00 +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}")
2025-05-10 23:59:00 +05:30
self.download_finished(0,0,False, [])
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):
2025-05-10 23:59:00 +05:30
global PostProcessorWorker
2025-05-10 11:07:27 +05:30
if self.thread_pool is None:
self.thread_pool = ThreadPoolExecutor(max_workers=num_post_workers, thread_name_prefix='PostWorker_')
2025-05-10 23:59:00 +05:30
self.active_futures = []
2025-05-10 11:07:27 +05:30
self.processed_posts_count = 0; self.total_posts_to_process = 0; self.download_counter = 0; self.skip_counter = 0
2025-05-10 23:59:00 +05:30
self.all_kept_original_filenames = []
2025-05-10 11:07:27 +05:30
2025-05-06 22:08:27 +05:30
fetcher_thread = threading.Thread(
2025-05-10 23:59:00 +05:30
target=self._fetch_and_queue_posts,
args=(kwargs['api_url_input'], kwargs, num_post_workers),
daemon=True,
name="PostFetcher"
2025-05-06 22:08:27 +05:30
)
2025-05-10 23:59:00 +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):
2025-05-10 23:59:00 +05:30
global PostProcessorWorker, download_from_api
all_posts_data = []
fetch_error_occurred = False
manga_mode_active_for_fetch = worker_args_template.get('manga_mode_active', False)
2025-05-10 11:07:27 +05:30
2025-05-09 19:03:01 +05:30
signals_for_worker = worker_args_template.get('signals')
2025-05-10 23:59:00 +05:30
if not signals_for_worker:
2025-05-10 11:07:27 +05:30
self.log_signal.emit("❌ CRITICAL ERROR: Signals object missing for worker in _fetch_and_queue_posts.");
2025-05-10 23:59:00 +05:30
self.finished_signal.emit(0,0,True, []);
2025-05-08 19:49:50 +05:30
return
2025-05-06 22:08:27 +05:30
2025-05-10 23:59:00 +05:30
try:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(" Fetching post data from API (this may take a moment for large feeds)...")
2025-05-10 23:59:00 +05:30
post_generator = download_from_api(
2025-05-08 19:49:50 +05:30
api_url_input_for_fetcher,
2025-05-10 23:59:00 +05:30
logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"),
2025-05-09 19:03:01 +05:30
start_page=worker_args_template.get('start_page'),
2025-05-08 19:49:50 +05:30
end_page=worker_args_template.get('end_page'),
2025-05-10 23:59:00 +05:30
manga_mode=manga_mode_active_for_fetch,
cancellation_event=self.cancellation_event
2025-05-08 19:49:50 +05:30
)
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
for posts_batch in post_generator:
if self.cancellation_event.is_set():
2025-05-08 19:49:50 +05:30
fetch_error_occurred = True; self.log_signal.emit(" Post fetching cancelled by user."); break
2025-05-10 23:59:00 +05:30
if isinstance(posts_batch, list):
all_posts_data.extend(posts_batch)
self.total_posts_to_process = len(all_posts_data)
if self.total_posts_to_process > 0 and self.total_posts_to_process % 100 == 0 :
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts so far...")
2025-05-10 23:59:00 +05:30
else:
2025-05-10 11:07:27 +05:30
fetch_error_occurred = True; self.log_signal.emit(f"❌ API fetcher returned non-list type: {type(posts_batch)}"); break
2025-05-10 23:59:00 +05:30
if not fetch_error_occurred and not self.cancellation_event.is_set():
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f"✅ Post fetching complete. Total posts to process: {self.total_posts_to_process}")
2025-05-10 11:07:27 +05:30
2025-05-10 23:59:00 +05:30
except TypeError as te:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"❌ TypeError calling download_from_api: {te}\n Check 'downloader_utils.py' signature.\n{traceback.format_exc(limit=2)}"); fetch_error_occurred = True
2025-05-10 23:59:00 +05:30
except RuntimeError as re_err:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f" Post fetching runtime error (likely cancellation or API issue): {re_err}"); fetch_error_occurred = True
2025-05-10 23:59:00 +05:30
except Exception as e:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"❌ Error during post fetching: {e}\n{traceback.format_exc(limit=2)}"); fetch_error_occurred = True
2025-05-08 19:49:50 +05:30
if self.cancellation_event.is_set() or fetch_error_occurred:
2025-05-10 11:07:27 +05:30
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames)
2025-05-10 23:59:00 +05:30
if self.thread_pool: 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
2025-05-10 23:59:00 +05:30
if self.total_posts_to_process == 0:
2025-05-10 11:07:27 +05:30
self.log_signal.emit("😕 No posts found or fetched to process.");
2025-05-10 23:59:00 +05:30
self.finished_signal.emit(0,0,False, []);
2025-05-10 11:07:27 +05:30
return
2025-05-08 19:49:50 +05:30
self.log_signal.emit(f" Submitting {self.total_posts_to_process} post processing tasks to thread pool...")
2025-05-10 23:59:00 +05:30
self.processed_posts_count = 0
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
2025-05-10 11:07:27 +05:30
num_file_dl_threads_for_each_worker = worker_args_template.get('num_file_threads_for_worker', 1)
2025-05-08 19:49:50 +05:30
ppw_expected_keys = [
2025-05-10 11:07:27 +05:30
'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',
2025-05-12 10:54:31 +05:30
'downloaded_files_lock', 'downloaded_file_hashes_lock', 'remove_from_filename_words_list',
2025-05-10 23:59:00 +05:30
'skip_words_list', 'skip_words_scope', 'char_filter_scope',
2025-05-12 10:54:31 +05:30
'show_external_links', 'extract_links_only', 'allow_multipart_download',
2025-05-10 23:59:00 +05:30
'num_file_threads',
'skip_current_file_flag',
2025-05-10 11:07:27 +05:30
'manga_mode_active', 'manga_filename_style'
2025-05-08 19:49:50 +05:30
]
2025-05-12 10:54:31 +05:30
# Ensure 'allow_multipart_download' is also considered for optional keys if it has a default in PostProcessorWorker
2025-05-08 19:49:50 +05:30
ppw_optional_keys_with_defaults = {
2025-05-12 10:54:31 +05:30
'skip_words_list', 'skip_words_scope', 'char_filter_scope', 'remove_from_filename_words_list',
2025-05-10 23:59:00 +05:30
'show_external_links', 'extract_links_only',
2025-05-10 11:07:27 +05:30
'num_file_threads', 'skip_current_file_flag', 'manga_mode_active', 'manga_filename_style'
2025-05-06 22:08:27 +05:30
}
2025-05-10 23:59:00 +05:30
for post_data_item in all_posts_data:
if self.cancellation_event.is_set(): break
if not isinstance(post_data_item, dict):
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"⚠️ Skipping invalid post data item (not a dict): {type(post_data_item)}");
2025-05-10 23:59:00 +05:30
self.processed_posts_count += 1;
2025-05-06 22:08:27 +05:30
continue
2025-05-08 19:49:50 +05:30
2025-05-10 23:59:00 +05:30
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_for_each_worker
elif key == 'signals': worker_init_args[key] = signals_for_worker
elif key in worker_args_template: worker_init_args[key] = worker_args_template[key]
elif key in ppw_optional_keys_with_defaults: pass
else: missing_keys.append(key)
if missing_keys:
2025-05-10 11:07:27 +05:30
self.log_signal.emit(f"❌ CRITICAL ERROR: Missing keys for PostProcessorWorker: {', '.join(missing_keys)}");
2025-05-10 23:59:00 +05:30
self.cancellation_event.set(); break
try:
worker_instance = PostProcessorWorker(**worker_init_args)
if self.thread_pool:
future = self.thread_pool.submit(worker_instance.process)
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
else:
2025-05-10 11:07:27 +05:30
self.log_signal.emit("⚠️ Thread pool not available. Cannot submit more tasks."); break
except TypeError as te: self.log_signal.emit(f"❌ TypeError creating PostProcessorWorker: {te}\n Passed Args: [{', '.join(sorted(worker_init_args.keys()))}]\n{traceback.format_exc(limit=5)}"); self.cancellation_event.set(); break
except RuntimeError: self.log_signal.emit("⚠️ Runtime error submitting task (pool likely shutting down)."); break
except Exception as e: self.log_signal.emit(f"❌ Error submitting post {post_data_item.get('id','N/A')} to worker: {e}"); break
if not self.cancellation_event.is_set(): self.log_signal.emit(f" {len(self.active_futures)} post processing tasks submitted to pool.")
2025-05-09 19:03:01 +05:30
else:
2025-05-10 11:07:27 +05:30
self.finished_signal.emit(self.download_counter, self.skip_counter, True, self.all_kept_original_filenames)
if self.thread_pool: self.thread_pool.shutdown(wait=False, cancel_futures=True); self.thread_pool = None
2025-05-06 22:08:27 +05:30
def _handle_future_result(self, future: Future):
self.processed_posts_count += 1
2025-05-10 11:07:27 +05:30
downloaded_files_from_future, skipped_files_from_future = 0, 0
kept_originals_from_future = []
2025-05-06 22:08:27 +05:30
try:
2025-05-10 11:07:27 +05:30
if future.cancelled(): self.log_signal.emit(" A post processing task was cancelled.")
elif future.exception(): self.log_signal.emit(f"❌ Post processing worker error: {future.exception()}")
else:
downloaded_files_from_future, skipped_files_from_future, kept_originals_from_future = future.result()
2025-05-08 19:49:50 +05:30
2025-05-09 19:03:01 +05:30
with self.downloaded_files_lock:
2025-05-10 11:07:27 +05:30
self.download_counter += downloaded_files_from_future
self.skip_counter += skipped_files_from_future
2025-05-05 19:35:24 +05:30
2025-05-10 11:07:27 +05:30
if kept_originals_from_future:
self.all_kept_original_filenames.extend(kept_originals_from_future)
2025-05-06 22:08:27 +05:30
2025-05-10 11:07:27 +05:30
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
except Exception as e: self.log_signal.emit(f"❌ Error in _handle_future_result callback: {e}\n{traceback.format_exc(limit=2)}")
2025-05-08 19:49:50 +05:30
if self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process:
2025-05-10 11:07:27 +05:30
if all(f.done() for f in self.active_futures):
2025-05-09 19:03:01 +05:30
QApplication.processEvents()
2025-05-08 19:49:50 +05:30
self.log_signal.emit("🏁 All submitted post tasks have completed or failed.")
2025-05-10 11:07:27 +05:30
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames)
2025-05-07 07:20:40 +05:30
2025-05-06 22:08:27 +05:30
def set_ui_enabled(self, enabled):
2025-05-10 11:07:27 +05:30
widgets_to_toggle = [ self.download_btn, self.link_input, self.radio_all, self.radio_images, self.radio_videos, self.radio_only_links,
self.skip_zip_checkbox, self.skip_rar_checkbox, self.use_subfolders_checkbox, self.compress_images_checkbox,
self.download_thumbnails_checkbox, self.use_multithreading_checkbox, self.skip_words_input, self.character_search_input,
2025-05-10 23:59:00 +05:30
self.new_char_input, self.add_char_button, self.delete_char_button,
self.char_filter_scope_toggle_button,
self.start_page_input, self.end_page_input,
2025-05-12 10:54:31 +05:30
self.page_range_label, self.to_label, self.character_input, self.custom_folder_input, self.custom_folder_label, self.remove_from_filename_input,
self.reset_button, self.manga_mode_checkbox, self.manga_rename_toggle_button, self.multipart_toggle_button,
2025-05-10 23:59:00 +05:30
self.skip_scope_toggle_button
2025-05-08 19:49:50 +05:30
]
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
for widget in widgets_to_toggle:
2025-05-10 11:07:27 +05:30
if widget: widget.setEnabled(enabled)
if enabled:
self._handle_filter_mode_change(self.radio_group.checkedButton(), True)
2025-05-10 23:59:00 +05:30
2025-05-08 19:49:50 +05:30
if self.external_links_checkbox:
2025-05-09 19:03:01 +05:30
is_only_links = self.radio_only_links and self.radio_only_links.isChecked()
2025-05-10 11:07:27 +05:30
self.external_links_checkbox.setEnabled(enabled and not is_only_links)
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
if self.log_verbosity_button: self.log_verbosity_button.setEnabled(True)
2025-05-09 19:03:01 +05:30
2025-05-08 19:49:50 +05:30
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)
2025-05-08 22:13:12 +05:30
subfolders_currently_on = self.use_subfolders_checkbox.isChecked()
2025-05-10 23:59:00 +05:30
self.use_subfolder_per_post_checkbox.setEnabled(enabled)
2025-05-08 19:49:50 +05:30
2025-05-09 19:03:01 +05:30
self.cancel_btn.setEnabled(not enabled)
2025-05-08 19:49:50 +05:30
2025-05-12 10:54:31 +05:30
if enabled: # Ensure these are updated based on current (possibly reset) checkbox states
2025-05-09 19:03:01 +05:30
self._handle_multithreading_toggle(multithreading_currently_on)
2025-05-10 11:07:27 +05:30
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
2025-05-12 10:54:31 +05:30
self.update_custom_folder_visibility(self.link_input.text())
self.update_page_range_enabled_state()
2025-05-07 07:20:40 +05:30
2025-05-12 10:54:31 +05:30
def _perform_soft_ui_reset(self, preserve_url=None, preserve_dir=None):
"""Resets UI elements and some state to app defaults, then applies preserved inputs."""
self.log_signal.emit("🔄 Performing soft UI reset...")
# 1. Reset UI fields to their visual defaults
self.link_input.clear() # Will be set later if preserve_url is given
self.dir_input.clear() # Will be set later if preserve_dir is given
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();
if hasattr(self, 'remove_from_filename_input'): self.remove_from_filename_input.clear()
self.character_search_input.clear(); self.thread_count_input.setText("4"); self.radio_all.setChecked(True);
self.skip_zip_checkbox.setChecked(True); self.skip_rar_checkbox.setChecked(True); self.download_thumbnails_checkbox.setChecked(False);
self.compress_images_checkbox.setChecked(False); self.use_subfolders_checkbox.setChecked(True);
self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True);
self.external_links_checkbox.setChecked(False)
if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False)
# 2. Reset internal state for UI-managed settings to app defaults (not from QSettings)
self.allow_multipart_download_setting = False # Default to OFF
self._update_multipart_toggle_button_text()
self.skip_words_scope = SKIP_SCOPE_POSTS # Default
self._update_skip_scope_button_text()
self.char_filter_scope = CHAR_SCOPE_TITLE # Default
self._update_char_filter_scope_button_text()
self.manga_filename_style = STYLE_POST_TITLE # Reset to app default
self._update_manga_filename_style_button_text()
# 3. Restore preserved URL and Directory
if preserve_url is not None:
self.link_input.setText(preserve_url)
if preserve_dir is not None:
self.dir_input.setText(preserve_dir)
# 4. Reset operational state variables (but not session-based downloaded_files/hashes)
self.external_link_queue.clear(); self.extracted_links_cache = []
self._is_processing_external_link_queue = False; self._current_link_post_title = None
self.total_posts_to_process = 0; self.processed_posts_count = 0
self.download_counter = 0; self.skip_counter = 0
self.all_kept_original_filenames = []
# 5. Update UI based on new (default or preserved) states
self._handle_filter_mode_change(self.radio_group.checkedButton(), True)
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
self.filter_character_list(self.character_search_input.text())
self.set_ui_enabled(True) # This enables buttons and calls other UI update methods
# Explicitly call these to ensure they reflect changes from preserved inputs
self.update_custom_folder_visibility(self.link_input.text())
self.update_page_range_enabled_state()
# update_ui_for_manga_mode is called within set_ui_enabled
self.log_signal.emit("✅ Soft UI reset complete. Preserved URL and Directory (if provided).")
def cancel_download_button_action(self):
2025-05-10 11:07:27 +05:30
if not self.cancel_btn.isEnabled() and not self.cancellation_event.is_set(): self.log_signal.emit(" No active download to cancel or already cancelling."); return
2025-05-12 10:54:31 +05:30
self.log_signal.emit("⚠️ Requesting cancellation of download process (soft reset)...")
current_url = self.link_input.text()
current_dir = self.dir_input.text()
self.cancellation_event.set()
2025-05-10 11:07:27 +05:30
if self.download_thread and self.download_thread.isRunning(): self.download_thread.requestInterruption(); self.log_signal.emit(" Signaled single download thread to interrupt.")
2025-05-12 10:54:31 +05:30
if self.thread_pool:
self.log_signal.emit(" Initiating non-blocking shutdown and cancellation of worker pool tasks...")
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.thread_pool = None # Allow recreation for next download
self.active_futures = []
2025-05-10 11:07:27 +05:30
self.external_link_queue.clear(); self._is_processing_external_link_queue = False; self._current_link_post_title = None
2025-05-12 10:54:31 +05:30
self._perform_soft_ui_reset(preserve_url=current_url, preserve_dir=current_dir)
self.progress_label.setText("Progress: Cancelled. Ready for new task.")
self.file_progress_label.setText("")
self.log_signal.emit(" UI reset. Ready for new operation. Background tasks are being terminated.")
2025-05-06 22:08:27 +05:30
2025-05-10 11:07:27 +05:30
def download_finished(self, total_downloaded, total_skipped, cancelled_by_user, kept_original_names_list=None):
if kept_original_names_list is None:
kept_original_names_list = self.all_kept_original_filenames if hasattr(self, 'all_kept_original_filenames') else []
if kept_original_names_list is None:
kept_original_names_list = []
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
status_message = "Cancelled by user" if cancelled_by_user else "Finished"
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
summary_log = "="*40
summary_log += f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n"
summary_log += "="*40
self.log_signal.emit(summary_log)
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
if kept_original_names_list:
intro_msg = (
HTML_PREFIX +
"<p> The following files from multi-file manga posts "
"(after the first file) kept their <b>original names</b>:</p>"
)
self.log_signal.emit(intro_msg)
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
html_list_items = "<ul>"
for name in kept_original_names_list:
html_list_items += f"<li><b>{name}</b></li>"
html_list_items += "</ul>"
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
self.log_signal.emit(HTML_PREFIX + html_list_items)
self.log_signal.emit("="*40)
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
self.progress_label.setText(f"{status_message}: {total_downloaded} downloaded, {total_skipped} skipped."); self.file_progress_label.setText("")
if not cancelled_by_user: self._try_process_next_external_link()
2025-05-08 19:49:50 +05:30
2025-05-06 22:08:27 +05:30
if self.download_thread:
2025-05-09 19:03:01 +05:30
try:
2025-05-08 19:49:50 +05:30
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)
2025-05-10 11:07:27 +05:30
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished)
2025-05-08 19:49:50 +05:30
if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result)
if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.disconnect(self.handle_external_link_signal)
if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.disconnect(self.update_file_progress_display)
2025-05-10 11:07:27 +05:30
except (TypeError, RuntimeError) as e: self.log_signal.emit(f" Note during single-thread signal disconnection: {e}")
2025-05-12 10:54:31 +05:30
# Ensure these are cleared if the download_finished is for the single download thread
if self.download_thread and not self.download_thread.isRunning(): # Check if it was this thread
self.download_thread = None
2025-05-10 11:07:27 +05:30
if self.thread_pool: self.log_signal.emit(" Ensuring worker thread pool is shut down..."); self.thread_pool.shutdown(wait=True, cancel_futures=True); self.thread_pool = None
2025-05-09 19:03:01 +05:30
self.active_futures = []
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
self.set_ui_enabled(True); self.cancel_btn.setEnabled(False)
2025-05-08 19:49:50 +05:30
def toggle_log_verbosity(self):
self.basic_log_mode = not self.basic_log_mode
2025-05-10 11:07:27 +05:30
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)
2025-05-08 19:49:50 +05:30
def reset_application_state(self):
2025-05-10 11:07:27 +05:30
if self._is_download_active(): QMessageBox.warning(self, "Reset Error", "Cannot reset while a download is in progress. Please cancel first."); return
self.log_signal.emit("🔄 Resetting application state to defaults..."); self._reset_ui_to_defaults()
self.main_log_output.clear(); self.external_log_output.clear()
if self.show_external_links and not (self.radio_only_links and self.radio_only_links.isChecked()): self.external_log_output.append("🔗 External Links Found:")
self.external_link_queue.clear(); self.extracted_links_cache = []; self._is_processing_external_link_queue = False; self._current_link_post_title = None
self.progress_label.setText("Progress: Idle"); self.file_progress_label.setText("")
with self.downloaded_files_lock: count = len(self.downloaded_files); self.downloaded_files.clear();
if count > 0: self.log_signal.emit(f" Cleared {count} downloaded filename(s) from session memory.")
with self.downloaded_file_hashes_lock: count = len(self.downloaded_file_hashes); self.downloaded_file_hashes.clear();
if count > 0: self.log_signal.emit(f" Cleared {count} downloaded file hash(es) from session memory.")
self.total_posts_to_process = 0; self.processed_posts_count = 0; self.download_counter = 0; self.skip_counter = 0
self.all_kept_original_filenames = []
self.cancellation_event.clear(); self.basic_log_mode = False
if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log")
2025-05-08 19:49:50 +05:30
2025-05-10 11:07:27 +05:30
self.manga_filename_style = STYLE_POST_TITLE
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
2025-05-10 23:59:00 +05:30
self.skip_words_scope = SKIP_SCOPE_POSTS
2025-05-10 11:07:27 +05:30
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
2025-05-10 23:59:00 +05:30
self._update_skip_scope_button_text()
self.char_filter_scope = CHAR_SCOPE_TITLE
self.settings.setValue(CHAR_FILTER_SCOPE_KEY, self.char_filter_scope)
self._update_char_filter_scope_button_text()
2025-05-09 19:03:01 +05:30
2025-05-12 10:54:31 +05:30
self.duplicate_file_mode = DUPLICATE_MODE_DELETE # Reset to default (Delete)
self.settings.setValue(DUPLICATE_FILE_MODE_KEY, self.duplicate_file_mode)
self._update_duplicate_mode_button_text()
2025-05-10 11:07:27 +05:30
self.settings.sync()
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
2025-05-08 19:49:50 +05:30
self.log_signal.emit("✅ Application reset complete.")
def _reset_ui_to_defaults(self):
2025-05-10 11:07:27 +05:30
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();
2025-05-12 10:54:31 +05:30
if hasattr(self, 'remove_from_filename_input'): self.remove_from_filename_input.clear()
2025-05-10 11:07:27 +05:30
self.character_search_input.clear(); self.thread_count_input.setText("4"); self.radio_all.setChecked(True);
self.skip_zip_checkbox.setChecked(True); self.skip_rar_checkbox.setChecked(True); self.download_thumbnails_checkbox.setChecked(False);
self.compress_images_checkbox.setChecked(False); self.use_subfolders_checkbox.setChecked(True);
self.use_subfolder_per_post_checkbox.setChecked(False); self.use_multithreading_checkbox.setChecked(True);
2025-05-09 19:03:01 +05:30
self.external_links_checkbox.setChecked(False)
2025-05-12 10:54:31 +05:30
if self.manga_mode_checkbox: self.manga_mode_checkbox.setChecked(False)
self.allow_multipart_download_setting = False # Default to OFF
self._update_multipart_toggle_button_text() # Update button text
2025-05-10 23:59:00 +05:30
self.skip_words_scope = SKIP_SCOPE_POSTS
self._update_skip_scope_button_text()
self.char_filter_scope = CHAR_SCOPE_TITLE
self._update_char_filter_scope_button_text()
2025-05-12 10:54:31 +05:30
self.duplicate_file_mode = DUPLICATE_MODE_DELETE # Default to DELETE
self._update_duplicate_mode_button_text()
2025-05-10 11:07:27 +05:30
2025-05-08 19:49:50 +05:30
2025-05-09 19:03:01 +05:30
self._handle_filter_mode_change(self.radio_all, True)
2025-05-08 22:13:12 +05:30
self._handle_multithreading_toggle(self.use_multithreading_checkbox.isChecked())
2025-05-09 19:03:01 +05:30
self.filter_character_list("")
2025-05-10 11:07:27 +05:30
self.download_btn.setEnabled(True); self.cancel_btn.setEnabled(False)
2025-05-08 19:49:50 +05:30
if self.reset_button: self.reset_button.setEnabled(True)
if self.log_verbosity_button: self.log_verbosity_button.setText("Show Basic Log")
2025-05-10 11:07:27 +05:30
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(False)
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-09 19:03:01 +05:30
global KNOWN_NAMES
2025-05-10 11:07:27 +05:30
reply = QMessageBox.question(self, "Add Filter Name to Known List?", f"The name '{character_name}' was encountered or used as a filter.\nIt's not in your known names list (used for folder suggestions).\nAdd it now?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
2025-05-07 07:20:40 +05:30
result = (reply == QMessageBox.Yes)
if result:
2025-05-09 19:03:01 +05:30
self.new_char_input.setText(character_name)
2025-05-10 11:07:27 +05:30
if self.add_new_character(): self.log_signal.emit(f"✅ Added '{character_name}' to known names via background prompt.")
else: result = False; self.log_signal.emit(f" Adding '{character_name}' via background prompt was declined or failed.")
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-10 11:07:27 +05:30
with QMutexLocker(self.prompt_mutex): 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-12 10:54:31 +05:30
def _update_multipart_toggle_button_text(self):
if hasattr(self, 'multipart_toggle_button'):
text = "Multi-part: ON" if self.allow_multipart_download_setting else "Multi-part: OFF"
self.multipart_toggle_button.setText(text)
def _toggle_multipart_mode(self):
# If currently OFF, and user is trying to turn it ON
if not self.allow_multipart_download_setting:
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("Multi-part Download Advisory")
msg_box.setText(
"<b>Multi-part download advisory:</b><br><br>"
"<ul>"
"<li>Best suited for <b>large files</b> (e.g., single post videos).</li>"
"<li>When downloading a full creator feed with many small files (like images):"
"<ul><li>May not offer significant speed benefits.</li>"
"<li>Could potentially make the UI feel <b>choppy</b>.</li>"
"<li>May <b>spam the process log</b> with rapid, numerous small download messages.</li></ul></li>"
"<li>Consider using the <b>'Videos' filter</b> if downloading a creator feed to primarily target large files for multi-part.</li>"
"</ul><br>"
"Do you want to enable multi-part download?"
)
proceed_button = msg_box.addButton("Proceed Anyway", QMessageBox.AcceptRole)
cancel_button = msg_box.addButton("Cancel", QMessageBox.RejectRole)
msg_box.setDefaultButton(proceed_button) # Default to Proceed
msg_box.exec_()
if msg_box.clickedButton() == cancel_button:
# User cancelled, so don't change the setting (it's already False)
self.log_signal.emit(" Multi-part download enabling cancelled by user.")
return # Exit without changing the state or button text
self.allow_multipart_download_setting = not self.allow_multipart_download_setting # Toggle the actual setting
self._update_multipart_toggle_button_text()
self.settings.setValue(ALLOW_MULTIPART_DOWNLOAD_KEY, self.allow_multipart_download_setting)
self.log_signal.emit(f" Multi-part download set to: {'Enabled' if self.allow_multipart_download_setting else 'Disabled'}")
def _update_duplicate_mode_button_text(self):
if hasattr(self, 'duplicate_mode_toggle_button'):
if self.duplicate_file_mode == DUPLICATE_MODE_DELETE:
self.duplicate_mode_toggle_button.setText("Duplicates: Delete")
elif self.duplicate_file_mode == DUPLICATE_MODE_MOVE_TO_SUBFOLDER:
self.duplicate_mode_toggle_button.setText("Duplicates: Move")
else: # Should not happen
self.duplicate_mode_toggle_button.setText("Duplicates: Move") # Default to Move if unknown
def _cycle_duplicate_mode(self):
if self.duplicate_file_mode == DUPLICATE_MODE_MOVE_TO_SUBFOLDER:
self.duplicate_file_mode = DUPLICATE_MODE_DELETE
else: # If it's DELETE or unknown, cycle back to MOVE
self.duplicate_file_mode = DUPLICATE_MODE_MOVE_TO_SUBFOLDER
self._update_duplicate_mode_button_text()
self.settings.setValue(DUPLICATE_FILE_MODE_KEY, self.duplicate_file_mode)
self.log_signal.emit(f" Duplicate file handling mode changed to: '{self.duplicate_file_mode.capitalize()}'")
2025-05-06 22:08:27 +05:30
2025-05-05 19:35:24 +05:30
if __name__ == '__main__':
2025-05-09 19:03:01 +05:30
import traceback
2025-05-08 19:49:50 +05:30
try:
qt_app = QApplication(sys.argv)
2025-05-10 11:07:27 +05:30
if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS
else: base_dir = os.path.dirname(os.path.abspath(__file__))
2025-05-08 19:49:50 +05:30
icon_path = os.path.join(base_dir, 'Kemono.ico')
2025-05-10 11:07:27 +05:30
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}")
2025-05-08 19:49:50 +05:30
downloader_app_instance = DownloaderApp()
2025-05-12 10:54:31 +05:30
# Set a reasonable default size before showing
downloader_app_instance.resize(1150, 780) # Adjusted default size
2025-05-08 19:49:50 +05:30
downloader_app_instance.show()
2025-05-12 10:54:31 +05:30
# Center the window on the screen after it's shown and sized
downloader_app_instance._center_on_screen()
2025-05-09 19:03:01 +05:30
2025-05-10 11:07:27 +05:30
if TourDialog:
2025-05-12 10:54:31 +05:30
tour_settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
tour_settings.setValue(TourDialog.TOUR_SHOWN_KEY, False)
tour_settings.sync()
print("[Main] Forcing tour to be active for this session.")
2025-05-09 19:03:01 +05:30
tour_result = TourDialog.run_tour_if_needed(downloader_app_instance)
2025-05-10 11:07:27 +05:30
if tour_result == QDialog.Accepted: print("Tour completed by user.")
elif tour_result == QDialog.Rejected: print("Tour skipped or was already shown.")
2025-05-09 19:03:01 +05:30
2025-05-08 19:49:50 +05:30
exit_code = qt_app.exec_()
2025-05-09 19:03:01 +05:30
print(f"Application finished with exit code: {exit_code}")
2025-05-08 19:49:50 +05:30
sys.exit(exit_code)
2025-05-10 11:07:27 +05:30
except SystemExit: pass
2025-05-08 19:49:50 +05:30
except Exception as e:
print("--- CRITICAL APPLICATION ERROR ---")
print(f"An unhandled exception occurred: {e}")
2025-05-09 19:03:01 +05:30
traceback.print_exc()
2025-05-08 19:49:50 +05:30
print("--- END CRITICAL ERROR ---")
2025-05-10 11:07:27 +05:30
sys.exit(1)