4 Commits

Author SHA1 Message Date
Yuvi9587
3209770d00 Update LICENSE 2025-07-23 20:14:01 -07:00
Yuvi9587
337cdd342c Commit 2025-07-23 20:08:44 -07:00
Yuvi9587
d54b013bbc commit 2025-07-22 07:00:34 -07:00
Yuvi9587
2785fc1121 Update EmptyPopupDialog.py 2025-07-19 20:27:55 -07:00
9 changed files with 765 additions and 240 deletions

24
LICENSE
View File

@@ -1,11 +1,21 @@
Custom License - No Commercial Use
MIT License
Copyright [Yuvi9587] [2025]
Copyright (c) [2025] [Yuvi9587]
Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for **non-commercial purposes only**, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
2. Proper credit must be given to the original author in any public use, distribution, or derivative works.
3. Commercial use, resale, or sublicensing of the Software or any derivative works is strictly prohibited without explicit written permission.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND...
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -59,6 +59,7 @@ LANGUAGE_KEY = "currentLanguageV1"
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
# --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>"

View File

@@ -41,6 +41,9 @@ class DownloadManager:
self.total_downloads = 0
self.total_skips = 0
self.all_kept_original_filenames = []
self.creator_profiles_dir = None
self.current_creator_name_for_profile = None
self.current_creator_profile_path = None
def _log(self, message):
"""Puts a progress message into the queue for the UI."""
@@ -58,6 +61,13 @@ class DownloadManager:
if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.")
return
creator_profile_data = self._setup_creator_profile(config)
creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_profile_data)
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
self.is_running = True
self.cancellation_event.clear()
self.pause_event.clear()
@@ -72,11 +82,11 @@ class DownloadManager:
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts:
fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data),
args=(config, restore_data, creator_profile_data), # Add argument here
daemon=True
)
fetcher_thread.start()
@@ -112,6 +122,11 @@ class DownloadManager:
try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set()
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_ids)
if restore_data:
all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids'])
@@ -196,12 +211,52 @@ class DownloadManager:
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
if history:
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
post_id = history.get('post_id')
if post_id and self.current_creator_profile_path:
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(profile_data)
except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile."""
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
if not self.current_creator_name_for_profile:
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
return {}
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(self.creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
if os.path.exists(self.current_creator_profile_path):
try:
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, data):
"""Saves the provided data to the current creator's profile file."""
if not self.current_creator_profile_path:
return
try:
temp_path = self.current_creator_profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, self.current_creator_profile_path)
except OSError as e:
self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}")
def cancel_session(self):
"""Cancels the current running session."""
if not self.is_running:

View File

@@ -238,13 +238,24 @@ class PostProcessorWorker:
if self.manga_mode_active:
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
filename_to_save_in_main_path = cleaned_original_api_filename
if self.manga_date_prefix and self.manga_date_prefix.strip():
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
if cleaned_prefix:
filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}"
else:
self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.")
# Get the post's publication or added date
published_date_str = self.post.get('published')
added_date_str = self.post.get('added')
formatted_date_str = "nodate" # Fallback if no date is found
date_to_use_str = published_date_str or added_date_str
if date_to_use_str:
try:
# Extract just the YYYY-MM-DD part from the timestamp
formatted_date_str = date_to_use_str.split('T')[0]
except Exception:
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
else:
self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
# Combine the date with the cleaned original filename
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
was_original_name_kept_flag = True
elif self.manga_filename_style == STYLE_POST_TITLE:
if post_title and post_title.strip():
@@ -582,6 +593,33 @@ class PostProcessorWorker:
os.remove(downloaded_part_file_path)
except OSError: pass
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
if (self.compress_images and downloaded_part_file_path and
is_image(api_original_filename) and
os.path.getsize(downloaded_part_file_path) > 1.5 * 1024 * 1024):
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
try:
with Image.open(downloaded_part_file_path) as img:
# Convert to RGB to avoid issues with paletted images or alpha channels in WebP
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
# Use an in-memory buffer to save the compressed image
output_buffer = BytesIO()
img.save(output_buffer, format='WebP', quality=85)
# This buffer now holds the compressed data
data_to_write_io = output_buffer
# Update the filename to use the .webp extension
base, _ = os.path.splitext(filename_to_save_in_main_path)
filename_to_save_in_main_path = f"{base}.webp"
self.logger(f" ✅ Compression successful. New size: {len(data_to_write_io.getvalue()) / (1024*1024):.2f} MB")
except Exception as e_compress:
self.logger(f" ⚠️ Failed to compress '{api_original_filename}': {e_compress}. Saving original file instead.")
data_to_write_io = None # Ensure we fall back to saving the original
effective_save_folder = target_folder_path
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
@@ -599,15 +637,17 @@ class PostProcessorWorker:
try:
if data_to_write_io:
# Write the compressed data from the in-memory buffer
with open(final_save_path, 'wb') as f_out:
time.sleep(0.05)
f_out.write(data_to_write_io.getvalue())
# Clean up the original downloaded part file
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try:
os.remove(downloaded_part_file_path)
except OSError as e_rem:
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
else:
# No compression was done, just rename the original file
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
time.sleep(0.1)
os.rename(downloaded_part_file_path, final_save_path)
@@ -680,7 +720,6 @@ class PostProcessorWorker:
else:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
def process(self):
result_tuple = (0, 0, [], [], [], None, None)
@@ -1385,7 +1424,17 @@ class PostProcessorWorker:
if not all_files_from_post_api:
self.logger(f" No files found to download for post {post_id}.")
result_tuple = (0, 0, [], [], [], None, None)
history_data_for_no_files_post = {
'post_title': post_title,
'post_id': post_id,
'service': self.service,
'user_id': self.user_id,
'top_file_name': "N/A (No Files)",
'num_files': 0,
'upload_date_str': post_data.get('published') or post_data.get('added') or "Unknown",
'download_location': determined_post_save_path_for_history
}
result_tuple = (0, 0, [], [], [], history_data_for_no_files_post, None)
return result_tuple
files_to_download_info_list = []

View File

@@ -15,9 +15,9 @@ except ImportError:
try:
import gdown
GDOWN_AVAILABLE = True
GDRIVE_AVAILABLE = True
except ImportError:
GDOWN_AVAILABLE = False
GDRIVE_AVAILABLE = False
# --- Helper Functions ---
@@ -46,75 +46,76 @@ def _get_filename_from_headers(headers):
# --- Main Service Downloader Functions ---
def download_mega_file(mega_link, download_path=".", logger_func=print):
def download_mega_file(mega_url, download_path, logger_func=print):
"""
Downloads a file from a public Mega.nz link.
Args:
mega_link (str): The public Mega.nz link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
Downloads a file from a Mega.nz URL.
Handles both public links and links that include a decryption key.
"""
if not MEGA_AVAILABLE:
logger_func("Error: mega.py library is not installed. Cannot download from Mega.")
logger_func(" Please install it: pip install mega.py")
raise ImportError("mega.py library not found.")
logger_func("Mega download failed: 'mega.py' library is not installed.")
return
logger_func(f" [Mega] Initializing Mega client...")
try:
mega_client = Mega()
m = mega_client.login()
logger_func(f" [Mega] Attempting to download from: {mega_link}")
if not os.path.exists(download_path):
os.makedirs(download_path, exist_ok=True)
logger_func(f" [Mega] Created download directory: {download_path}")
# The download_url method handles file info fetching and saving internally.
downloaded_file_path = m.download_url(mega_link, dest_path=download_path)
mega = Mega()
# Anonymous login is sufficient for public links
m = mega.login()
if downloaded_file_path and os.path.exists(downloaded_file_path):
logger_func(f" [Mega] ✅ File downloaded successfully! Saved as: {downloaded_file_path}")
else:
raise Exception(f"Mega download failed or file not found. Returned: {downloaded_file_path}")
# --- MODIFIED PART: Added error handling for invalid links ---
try:
file_details = m.find(mega_url)
if file_details is None:
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
return
except (ValueError, json.JSONDecodeError) as e:
# This block catches the "Expecting value" error
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
return
except Exception as e:
# Catch other potential errors from the mega.py library
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
return
# --- END OF MODIFIED PART ---
filename = file_details[1]['a']['n']
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
# Sanitize filename before saving
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
final_path = os.path.join(download_path, safe_filename)
# Check if file already exists
if os.path.exists(final_path):
logger_func(f" [Mega] File '{safe_filename}' already exists. Skipping download.")
return
# Start the download
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
except Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during Mega download: {e}")
traceback.print_exc(limit=2)
raise # Re-raise the exception to be handled by the calling worker
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
def download_gdrive_file(gdrive_link, download_path=".", logger_func=print):
"""
Downloads a file from a public Google Drive link using the gdown library.
Args:
gdrive_link (str): The public Google Drive link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
"""
if not GDOWN_AVAILABLE:
logger_func("❌ Error: gdown library is not installed. Cannot download from Google Drive.")
logger_func(" Please install it: pip install gdown")
raise ImportError("gdown library not found.")
logger_func(f" [GDrive] Attempting to download: {gdrive_link}")
def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link."""
if not GDRIVE_AVAILABLE:
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
return
try:
if not os.path.exists(download_path):
os.makedirs(download_path, exist_ok=True)
logger_func(f" [GDrive] Created download directory: {download_path}")
# gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats.
output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True)
if output_file_path and os.path.exists(output_file_path):
logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}")
logger_func(f" [G-Drive] Starting download for: {url}")
# --- MODIFIED PART: Added a message and set quiet=True ---
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
# --- END OF MODIFIED PART ---
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
else:
raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}")
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
except Exception as e:
logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}")
traceback.print_exc(limit=2)
raise
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
"""

View File

@@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
QSplitter, QProgressBar, QWidget
QSplitter, QProgressBar, QWidget, QFileDialog
)
# --- Local Application Imports ---
@@ -151,6 +151,8 @@ class EmptyPopupDialog (QDialog ):
app_icon =get_app_icon_object ()
if app_icon and not app_icon .isNull ():
self .setWindowIcon (app_icon )
self.update_profile_data = None
self.update_creator_name = None
self .selected_creators_for_queue =[]
self .globally_selected_creators ={}
self .fetched_posts_data ={}
@@ -205,6 +207,9 @@ class EmptyPopupDialog (QDialog ):
self .scope_button .clicked .connect (self ._toggle_scope_mode )
left_bottom_buttons_layout .addWidget (self .scope_button )
left_pane_layout .addLayout (left_bottom_buttons_layout )
self.update_button = QPushButton()
self.update_button.clicked.connect(self._handle_update_check)
left_bottom_buttons_layout.addWidget(self.update_button)
self .right_pane_widget =QWidget ()
@@ -315,6 +320,31 @@ class EmptyPopupDialog (QDialog ):
except AttributeError :
pass
def _handle_update_check(self):
"""Opens a dialog to select a creator profile and loads it for an update session."""
appdata_dir = os.path.join(self.app_base_dir, "appdata")
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
if not os.path.isdir(profiles_dir):
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
return
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
if filepath:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'creator_url' not in data or 'processed_post_ids' not in data:
raise ValueError("Invalid profile format.")
self.update_profile_data = data
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
self.accept() # Close the dialog and signal success
except Exception as e:
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
def _handle_fetch_posts_click (self ):
selected_creators =list (self .globally_selected_creators .values ())
print(f"[DEBUG] Selected creators for fetch: {selected_creators}")
@@ -370,6 +400,7 @@ class EmptyPopupDialog (QDialog ):
self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected"))
self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts"))
self ._update_scope_button_text_and_tooltip ()
self.update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))
@@ -1003,4 +1034,4 @@ class EmptyPopupDialog (QDialog ):
else :
if unique_key in self .globally_selected_creators :
del self .globally_selected_creators [unique_key ]
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))

View File

@@ -6,7 +6,7 @@ import json
from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
)
# --- Local Application Imports ---
@@ -15,7 +15,7 @@ from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object
from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY
)
@@ -35,7 +35,7 @@ class FutureSettingsDialog(QDialog):
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0
base_min_w, base_min_h = 420, 320 # Adjusted height for new layout
base_min_w, base_min_h = 420, 360 # Adjusted height for new layout
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
@@ -93,6 +93,11 @@ class FutureSettingsDialog(QDialog):
download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1)
# Save Creator.json Checkbox
self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box)
main_layout.addStretch(1)
@@ -102,6 +107,20 @@ class FutureSettingsDialog(QDialog):
self.ok_button.clicked.connect(self.accept)
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
def _load_checkbox_states(self):
"""Loads the initial state for all checkboxes from settings."""
self.save_creator_json_checkbox.blockSignals(True)
# Default to True so the feature is on by default for users
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
self.save_creator_json_checkbox.setChecked(should_save)
self.save_creator_json_checkbox.blockSignals(False)
def _creator_json_setting_changed(self, state):
"""Saves the state of the 'Save Creator.json' checkbox."""
is_checked = state == Qt.Checked
self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked)
self.parent_app.settings.sync()
def _tr(self, key, default_text=""):
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
@@ -122,6 +141,7 @@ class FutureSettingsDialog(QDialog):
# Download & Window Group Labels
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
# Buttons and Controls
self._update_theme_toggle_button_text()
@@ -132,6 +152,7 @@ class FutureSettingsDialog(QDialog):
# Populate dropdowns
self._populate_display_combo_boxes()
self._populate_language_combo_box()
self._load_checkbox_states()
def _apply_theme(self):
if self.parent_app and self.parent_app.current_theme == "dark":

View File

@@ -23,6 +23,11 @@ from PyQt5.QtWidgets import (
QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame,
QMainWindow, QAction, QGridLayout,
)
try:
from PIL import Image
except ImportError:
Image = None
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker, QCoreApplication
from ..services.drive_downloader import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file
from ..core.workers import DownloadThread as BackendDownloadThread
@@ -97,6 +102,8 @@ class DownloaderApp (QWidget ):
def __init__(self):
super().__init__()
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
self.active_update_profile = None
self.new_posts_for_update = []
self.is_finishing = False
saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
@@ -113,9 +120,13 @@ class DownloaderApp (QWidget ):
else:
self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
self.config_file = os.path.join(self.app_base_dir, "appdata", "Known.txt")
self.session_file_path = os.path.join(self.app_base_dir, "appdata", "session.json")
self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json")
executable_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else self.app_base_dir
user_data_path = os.path.join(executable_dir, "appdata")
os.makedirs(user_data_path, exist_ok=True)
self.config_file = os.path.join(user_data_path, "Known.txt")
self.session_file_path = os.path.join(user_data_path, "session.json")
self.persistent_history_file = os.path.join(user_data_path, "download_history.json")
self.download_thread = None
self.thread_pool = None
@@ -222,6 +233,7 @@ class DownloaderApp (QWidget ):
self.downloaded_hash_counts = defaultdict(int)
self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = []
self.save_creator_json_enabled_this_session = True
print(f" Known.txt will be loaded/saved at: {self.config_file}")
@@ -253,7 +265,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None
self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v6.1.0")
self.setWindowTitle("Kemono Downloader v6.2.0")
setup_ui(self)
self._connect_signals()
self.log_signal.emit(" Local API server functionality has been removed.")
@@ -294,6 +306,45 @@ class DownloaderApp (QWidget ):
if msg_box.clickedButton() == restart_button:
self._request_restart_application()
def _setup_creator_profile(self, creator_name, session_file_path):
"""Prepares the path and loads data for the current creator's profile."""
if not creator_name:
self.log_signal.emit("⚠️ Cannot create creator profile: Name not provided.")
return {}
appdata_dir = os.path.dirname(session_file_path)
creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(creator_name) + ".json"
profile_path = os.path.join(creator_profiles_dir, safe_filename)
if os.path.exists(profile_path):
try:
with open(profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self.log_signal.emit(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, creator_name, data, session_file_path):
"""Saves the provided data to the current creator's profile file."""
if not creator_name:
return
appdata_dir = os.path.dirname(session_file_path)
creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
safe_filename = clean_folder_name(creator_name) + ".json"
profile_path = os.path.join(creator_profiles_dir, safe_filename)
try:
temp_path = profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, profile_path)
except OSError as e:
self.log_signal.emit(f"❌ Error saving creator profile to '{profile_path}': {e}")
def _create_initial_session_file(self, api_url_for_session, override_output_dir_for_session, remaining_queue=None):
"""Creates the initial session file at the start of a new download."""
if self.is_restore_pending:
@@ -478,7 +529,7 @@ class DownloaderApp (QWidget ):
def _update_button_states_and_connections(self):
"""
Updates the text and click connections of the main action buttons
based on the current application state (downloading, paused, restore pending, idle).
based on the current application state.
"""
try: self.download_btn.clicked.disconnect()
except TypeError: pass
@@ -489,7 +540,38 @@ class DownloaderApp (QWidget ):
is_download_active = self._is_download_active()
if self.is_restore_pending:
if self.active_update_profile and self.new_posts_for_update and not is_download_active:
# State: Update confirmation (new posts found, waiting for user to start)
num_new = len(self.new_posts_for_update)
self.download_btn.setText(self._tr("start_download_new_button_text", f"⬇️ Start Download ({num_new} new)"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.download_btn.setToolTip(self._tr("start_download_new_tooltip", "Click to download the new posts found."))
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False)
self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_update_selection)
self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to cancel the update and clear the selection."))
elif self.active_update_profile and not is_download_active:
# State: Update check (profile loaded, waiting for user to check)
self.download_btn.setText(self._tr("check_for_updates_button_text", "🔄 Check For Updates"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.download_btn.setToolTip(self._tr("check_for_updates_tooltip", "Click to check for new posts from this creator."))
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False)
self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_update_selection)
self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to clear the loaded creator profile and return to normal mode."))
elif self.is_restore_pending:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
@@ -507,7 +589,7 @@ class DownloaderApp (QWidget ):
elif is_download_active:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(False) # Cannot start new download while one is active
self.download_btn.setEnabled(False)
self.pause_btn.setText(self._tr("resume_download_button_text", "▶️ Resume Download") if self.is_paused else self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(True)
@@ -524,13 +606,19 @@ class DownloaderApp (QWidget ):
self.download_btn.clicked.connect(self.start_download)
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False) # No active download to pause
self.pause_btn.setEnabled(False)
self.pause_btn.setToolTip(self._tr("pause_download_button_tooltip", "Click to pause the ongoing download process."))
self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
self.cancel_btn.setEnabled(False) # No active download to cancel
self.cancel_btn.setEnabled(False)
self.cancel_btn.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
def _clear_update_selection(self):
"""Clears the loaded creator profile and fully resets the UI to its default state."""
self.log_signal.emit(" Update selection cleared. Resetting UI to defaults.")
self.active_update_profile = None
self.new_posts_for_update = []
self._reset_ui_to_defaults()
def _retranslate_main_ui (self ):
"""Retranslates static text elements in the main UI."""
@@ -1618,42 +1706,75 @@ class DownloaderApp (QWidget ):
def _display_and_schedule_next (self ,link_data ):
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode:
# --- FONT STYLE DEFINITION ---
# Use a clean, widely available sans-serif font family for a modern look.
font_style = "font-family: 'Segoe UI', Helvetica, Arial, sans-serif;"
if is_only_links_mode :
if post_title != self._current_link_post_title:
if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html)
title_html = f'{HTML_PREFIX}<h3 style="color: #87CEEB; margin-bottom: 5px; margin-top: 8px;">{html.escape(post_title)}</h3>'
self.log_signal.emit(title_html)
# Apply font style to the title
title_html = f'<p style="{font_style} font-size: 11pt; color: #87CEEB; margin-top: 2px; margin-bottom: 2px;"><b>{html.escape(post_title)}</b></p>'
self.log_signal.emit(HTML_PREFIX + title_html)
self._current_link_post_title = post_title
display_text = html.escape(link_text.strip() if link_text.strip() else link_url)
link_html_parts = [
f'<div style="margin-left: 20px; margin-bottom: 4px;">'
f'• <a href="{link_url}" style="color: #A9D0F5; text-decoration: none;">{display_text}</a>'
f' <span style="color: #999;">({html.escape(platform)})</span>'
]
# Use the "smarter" logic to decide what text to show for the link
cleaned_link_text = link_text.strip()
display_text = ""
if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url:
display_text = cleaned_link_text
else:
try:
path = urlparse(link_url).path
filename = os.path.basename(path)
if filename:
display_text = filename
except Exception:
pass
if not display_text:
display_text = link_url
# Truncate long display text
if len(display_text) > 50:
display_text = display_text[:50].strip() + "..."
# Format the output as requested
platform_display = platform.capitalize()
# Escape parts that will be displayed as text
escaped_url = html.escape(link_url)
escaped_display_text = html.escape(f"({display_text})")
# Apply font style to the link information and wrap in a paragraph tag
link_html_line = (
f'<p style="{font_style} font-size: 9.5pt; margin-left: 10px; margin-top: 1px; margin-bottom: 1px;">'
f" <span style='color: #E0E0E0;'>{platform_display} - {escaped_url} - {escaped_display_text}</span>"
)
if decryption_key:
link_html_parts.append(
f'<br><span style="margin-left: 15px; color: #f0ad4e; font-size: 9pt;">'
f'Key: {html.escape(decryption_key)}</span>'
)
escaped_key = html.escape(f"(Decryption Key: {decryption_key})")
link_html_line += f" <span style='color: #f0ad4e;'>{escaped_key}</span>"
link_html_parts.append('</div>')
final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}'
self.log_signal.emit(final_link_html)
link_html_line += '</p>'
# Emit the entire line as a single HTML signal
self.log_signal.emit(HTML_PREFIX + link_html_line)
elif self .show_external_links :
separator ="-"*45
# This part for the secondary log remains unchanged
separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}"
if decryption_key:
formatted_link_info += f" (Decryption Key: {decryption_key})"
self._append_to_external_log(formatted_link_info, separator)
self ._is_processing_external_link_queue =False
self ._is_processing_external_link_queue =False
self ._try_process_next_external_link ()
@@ -1912,70 +2033,97 @@ class DownloaderApp (QWidget ):
def _filter_links_log (self ):
if not (self .radio_only_links and self .radio_only_links .isChecked ()):return
if not (self .radio_only_links and self .radio_only_links .isChecked ()):return
search_term =self .link_search_input .text ().lower ().strip ()if self .link_search_input else ""
# This block handles the "Download Progress" view for Mega/Drive links and should be kept
if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log (due to mega_download_log_preserved_once).")
self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log.")
return
elif self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
self .log_signal .emit ("INTERNAL: _filter_links_log - In Progress View. Clearing for placeholder.")
if self .main_log_output :self .main_log_output .clear ()
self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for progress placeholder.")
self .log_signal .emit (" Switched to Mega download progress view. Extracted links are hidden.\n"
" Perform a Mega download to see its progress here, or switch back to 🔗 view.")
self .log_signal .emit ("INTERNAL: _filter_links_log - Placeholder message emitted.")
return
else :
# Simplified logic: Clear the log and re-trigger the display process
# The main display logic is now fully handled by _display_and_schedule_next
if self .main_log_output :self .main_log_output .clear ()
self._current_link_post_title = None # Reset the title tracking for the new display pass
self .log_signal .emit ("INTERNAL: _filter_links_log - In links view branch. About to clear.")
if self .main_log_output :self .main_log_output .clear ()
self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for links view.")
current_title_for_display =None
any_links_displayed_this_call =False
separator_html ="<br>"+"-"*45 +"<br>"
for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
matches_search =(not search_term or
search_term in link_text .lower ()or
search_term in link_url .lower ()or
search_term in platform .lower ()or
(decryption_key and search_term in decryption_key .lower ()))
if not matches_search :
continue
any_links_displayed_this_call =True
if post_title !=current_title_for_display :
if current_title_for_display is not None :
if self .main_log_output :self .main_log_output .insertHtml (separator_html )
title_html =f'<b style="color: #87CEEB;">{html .escape (post_title )}</b><br>'
if self .main_log_output :self .main_log_output .insertHtml (title_html )
current_title_for_display =post_title
max_link_text_len =50
display_text =(link_text [:max_link_text_len ].strip ()+"..."if len (link_text )>max_link_text_len else link_text .strip ())
plain_link_info_line =f"{display_text } - {link_url } - {platform }"
if decryption_key :
plain_link_info_line +=f" (Decryption Key: {decryption_key })"
if self .main_log_output :
self .main_log_output .append (plain_link_info_line )
if any_links_displayed_this_call :
if self .main_log_output :self .main_log_output .append ("")
elif not search_term and self .main_log_output :
self .log_signal .emit (" (No links extracted yet or all filtered out in links view)")
# Create a new temporary queue containing only the links that match the search term
filtered_link_queue = deque()
for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
matches_search =(not search_term or
search_term in link_text .lower ()or
search_term in link_url .lower ()or
search_term in platform .lower ()or
(decryption_key and search_term in decryption_key .lower ()))
if matches_search :
filtered_link_queue.append((post_title, link_text, link_url, platform, decryption_key))
if not filtered_link_queue:
self .log_signal .emit (" (No links extracted yet or all filtered out in links view)")
else:
self.external_link_queue.clear()
self.external_link_queue.extend(filtered_link_queue)
self._try_process_next_external_link()
if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ())
def _display_and_schedule_next (self ,link_data ):
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode :
if post_title != self._current_link_post_title:
if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html)
title_html = f'<p style="font-size: 11pt; color: #87CEEB; margin-top: 2px; margin-bottom: 2px;"><b>{html.escape(post_title)}</b></p>'
self.log_signal.emit(HTML_PREFIX + title_html)
self._current_link_post_title = post_title
# Use the "smarter" logic to decide what text to show for the link
cleaned_link_text = link_text.strip()
display_text = ""
if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url:
display_text = cleaned_link_text
else:
try:
path = urlparse(link_url).path
filename = os.path.basename(path)
if filename:
display_text = filename
except Exception:
pass
if not display_text:
display_text = link_url
# Truncate long display text
if len(display_text) > 50:
display_text = display_text[:50].strip() + "..."
# --- NEW: Format the output as requested ---
platform_display = platform.capitalize()
plain_link_info_line = f" {platform_display} - {link_url} - ({display_text})"
if decryption_key:
plain_link_info_line += f" (Decryption Key: {decryption_key})"
self.main_log_output.append(plain_link_info_line)
elif self .show_external_links :
# This part for the secondary log remains unchanged
separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}"
if decryption_key:
formatted_link_info += f" (Decryption Key: {decryption_key})"
self._append_to_external_log(formatted_link_info, separator)
self ._is_processing_external_link_queue =False
self ._try_process_next_external_link ()
def _export_links_to_file (self ):
if not (self .radio_only_links and self .radio_only_links .isChecked ()):
@@ -2295,7 +2443,10 @@ class DownloaderApp (QWidget ):
_ ,_ ,post_id =extract_post_info (url_text .strip ())
is_single_post_url =bool (post_id )
subfolders_enabled =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False
subfolders_by_known_txt_enabled = getattr(self, 'use_subfolders_checkbox', None) and self.use_subfolders_checkbox.isChecked()
subfolder_per_post_enabled = getattr(self, 'use_subfolder_per_post_checkbox', None) and self.use_subfolder_per_post_checkbox.isChecked()
any_subfolder_option_enabled = subfolders_by_known_txt_enabled or subfolder_per_post_enabled
not_only_links_or_archives_mode =not (
(self .radio_only_links and self .radio_only_links .isChecked ())or
@@ -2303,7 +2454,7 @@ class DownloaderApp (QWidget ):
(hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ())
)
should_show_custom_folder =is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode
should_show_custom_folder =is_single_post_url and any_subfolder_option_enabled and not_only_links_or_archives_mode
if self .custom_folder_widget :
self .custom_folder_widget .setVisible (should_show_custom_folder )
@@ -2383,7 +2534,7 @@ class DownloaderApp (QWidget ):
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_title_text","Name: Post Title"))
elif self .manga_filename_style ==STYLE_ORIGINAL_NAME :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_original_file_text","Name: Original File"))
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_original_text","Date + Original"))
elif self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_title_global_num_text","Name: Title+G.Num"))
@@ -2400,7 +2551,6 @@ class DownloaderApp (QWidget ):
else :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_unknown_text","Name: Unknown Style"))
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).")
def _toggle_manga_filename_style (self ):
@@ -2516,8 +2666,7 @@ class DownloaderApp (QWidget ):
show_date_prefix_input =(
manga_mode_effectively_on and
(current_filename_style ==STYLE_DATE_BASED or
current_filename_style ==STYLE_ORIGINAL_NAME )and
(current_filename_style ==STYLE_DATE_BASED) and
not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )
)
if hasattr (self ,'manga_date_prefix_input'):
@@ -2607,6 +2756,12 @@ class DownloaderApp (QWidget ):
self .file_progress_label .setText ("")
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False):
if self.active_update_profile:
if not self.new_posts_for_update:
return self._check_for_updates()
else:
return self._start_confirmed_update_download()
self.is_finishing = False
self.downloaded_hash_counts.clear()
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER
@@ -2724,6 +2879,52 @@ class DownloaderApp (QWidget ):
return True
self.cancellation_message_logged_this_session = False
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
# Read the setting at the start of the download
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
profile_processed_ids = set() # Default to an empty set
if self.save_creator_json_enabled_this_session:
# --- CREATOR PROFILE LOGIC ---
creator_name_for_profile = None
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
else:
creator_key = (service.lower(), str(user_id))
creator_name_for_profile = self.creator_name_cache.get(creator_key)
if not creator_name_for_profile:
creator_name_for_profile = f"{service}_{user_id}"
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
# Get all current UI settings and add them to the profile
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
creator_profile_data['settings'] = current_settings
creator_profile_data.setdefault('creator_url', [])
if api_url not in creator_profile_data['creator_url']:
creator_profile_data['creator_url'].append(api_url)
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
# --- END OF PROFILE LOGIC ---
# The rest of this logic runs regardless, but uses the profile data if it was loaded
session_processed_ids = set(processed_post_ids_for_restore)
combined_processed_ids = session_processed_ids.union(profile_processed_ids)
processed_post_ids_for_this_run = list(combined_processed_ids)
use_subfolders = self.use_subfolders_checkbox.isChecked()
use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked()
compress_images = self.compress_images_checkbox.isChecked()
@@ -2831,15 +3032,6 @@ class DownloaderApp (QWidget ):
if backend_filter_mode == 'audio':
effective_skip_zip = self.skip_zip_checkbox.isChecked()
if not api_url:
QMessageBox.critical(self, "Input Error", "URL is required.")
return False
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
creator_folder_ignore_words_for_run = None
is_full_creator_download = not post_id_from_url
@@ -3187,7 +3379,7 @@ class DownloaderApp (QWidget ):
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'skip_current_file_flag': None,
'processed_post_ids': processed_post_ids_for_restore,
'processed_post_ids': processed_post_ids_for_this_run,
'start_offset': start_offset_for_restore,
}
@@ -3232,6 +3424,7 @@ class DownloaderApp (QWidget ):
self.is_paused = False
return True
def restore_download(self):
"""Initiates the download restoration process."""
if self._is_download_active():
@@ -3618,7 +3811,10 @@ class DownloaderApp (QWidget ):
if permanent:
self.permanently_failed_files_for_dialog.extend(permanent)
self._update_error_button_count()
if history_data: self._add_to_history_candidates(history_data)
if history_data:
# This single call now correctly handles both history and profile saving.
self._add_to_history_candidates(history_data)
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
@@ -3667,13 +3863,28 @@ class DownloaderApp (QWidget ):
create_single_pdf_from_content(sorted_content, filepath, font_path, logger=self.log_signal.emit)
self.log_signal.emit("="*40)
def _add_to_history_candidates (self ,history_data ):
"""Adds processed post data to the history candidates list."""
if history_data and len (self .download_history_candidates )<8 :
history_data ['download_date_timestamp']=time .time ()
creator_key =(history_data .get ('service','').lower (),str (history_data .get ('user_id','')))
history_data ['creator_name']=self .creator_name_cache .get (creator_key ,history_data .get ('user_id','Unknown'))
self .download_history_candidates .append (history_data )
def _add_to_history_candidates(self, history_data):
"""Adds processed post data to the history candidates list and updates the creator profile."""
if self.save_creator_json_enabled_this_session:
post_id = history_data.get('post_id')
service = history_data.get('service')
user_id = history_data.get('user_id')
if post_id and service and user_id:
creator_key = (service.lower(), str(user_id))
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{user_id}")
# Load the profile data before using it to prevent NameError
profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(creator_name, profile_data, self.session_file_path)
if history_data and len(self.download_history_candidates) < 8:
history_data['download_date_timestamp'] = time.time()
creator_key = (history_data.get('service','').lower(), str(history_data.get('user_id','')))
history_data['creator_name'] = self.creator_name_cache.get(creator_key, history_data.get('user_id','Unknown'))
self.download_history_candidates.append(history_data)
def _finalize_download_history (self ):
"""Processes candidates and selects the final 3 history entries.
@@ -3864,8 +4075,8 @@ class DownloaderApp (QWidget ):
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 .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 .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (False );
self .use_subfolder_per_post_checkbox .setChecked (True );self .use_multithreading_checkbox .setChecked (True );
if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False )
if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setChecked (False )
self .external_links_checkbox .setChecked (False )
@@ -4121,6 +4332,7 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(True)
self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False
self.active_update_profile = None
def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is checked."""
@@ -4372,55 +4584,40 @@ class DownloaderApp (QWidget ):
if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
def reset_application_state(self):
if self._is_download_active():
if self.download_thread and self.download_thread.isRunning():
self.log_signal.emit("⚠️ Cancelling active download thread for reset...")
self.cancellation_event.set()
self.download_thread.requestInterruption()
self.download_thread.wait(3000)
if self.download_thread.isRunning():
self.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.")
self.download_thread.deleteLater()
self.download_thread = None
if self.thread_pool:
self.log_signal.emit(" Shutting down thread pool for reset...")
self.thread_pool.shutdown(wait=True, cancel_futures=True)
self.thread_pool = None
self.active_futures = []
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.log_signal.emit(" Cancelling external link download thread for reset...")
self.external_link_download_thread.cancel()
self.external_link_download_thread.wait(3000)
self.external_link_download_thread.deleteLater()
self.external_link_download_thread = None
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Shutting down retry thread pool for reset...")
self.retry_thread_pool.shutdown(wait=True)
self.retry_thread_pool = None
if hasattr(self, 'active_retry_futures'):
self.active_retry_futures.clear()
if hasattr(self, 'active_retry_futures_map'):
self.active_retry_futures_map.clear()
self.cancellation_event.clear()
if self.pause_event:
self.pause_event.clear()
self.is_paused = False
self.log_signal.emit("🔄 Resetting application state to defaults...")
# --- MODIFIED PART: Signal all threads to stop first, but do not wait ---
if self._is_download_active():
self.log_signal.emit(" Cancelling all active background tasks for reset...")
self.cancellation_event.set() # Signal all threads to stop
# Initiate non-blocking shutdowns
if self.download_thread and self.download_thread.isRunning():
self.download_thread.requestInterruption()
if self.thread_pool:
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.thread_pool = None
if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.external_link_download_thread.cancel()
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
self.retry_thread_pool = None
self.cancellation_event.clear() # Reset the event for the next run
# --- END OF MODIFIED PART ---
if self.pause_event:
self.pause_event.clear()
self.is_paused = False
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._clear_session_file()
self._reset_ui_to_defaults()
self._load_saved_download_location()
self.main_log_output.clear()
self.external_log_output.clear()
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._reset_ui_to_defaults()
self._load_saved_download_location()
self.main_log_output.clear()
self.external_log_output.clear()
if self.missed_character_log_output:
self.missed_character_log_output.clear()
self.current_log_view = 'progress'
if self.log_view_stack:
self.log_view_stack.setCurrentIndex(0)
@@ -4448,7 +4645,7 @@ class DownloaderApp (QWidget ):
self.only_links_log_display_mode = LOG_DISPLAY_LINKS
self.mega_download_log_preserved_once = False
self.permanently_failed_files_for_dialog.clear()
self._update_error_button_count()
self._update_error_button_count()
self.favorite_download_scope = FAVORITE_SCOPE_SELECTED_LOCATION
self._update_favorite_scope_button_text()
self.retryable_failed_files_info.clear()
@@ -4463,13 +4660,14 @@ class DownloaderApp (QWidget ):
self.is_fetcher_thread_running = False
self.interrupted_session_data = None
self.is_restore_pending = False
self.active_update_profile = None
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
self.settings.sync()
self._update_manga_filename_style_button_text()
self.update_ui_for_manga_mode(self.manga_mode_checkbox.isChecked() if self.manga_mode_checkbox else False)
self.set_ui_enabled(True)
self.log_signal.emit("✅ Application fully reset. Ready for new download.")
self.is_processing_favorites_queue = False
@@ -4477,7 +4675,7 @@ class DownloaderApp (QWidget ):
self.favorite_download_queue.clear()
self.interrupted_session_data = None
self.is_restore_pending = False
self.last_link_input_text_for_queue_sync = ""
self.last_link_input_text_for_queue_sync = ""
def _reset_ui_to_defaults(self):
"""Resets all UI elements and relevant state to their default values."""
@@ -4498,8 +4696,8 @@ class DownloaderApp (QWidget ):
self.skip_zip_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_subfolders_checkbox.setChecked(False)
self.use_subfolder_per_post_checkbox.setChecked(True)
self.use_multithreading_checkbox.setChecked(True)
if self.favorite_mode_checkbox:
self.favorite_mode_checkbox.setChecked(False)
@@ -4773,6 +4971,144 @@ class DownloaderApp (QWidget ):
self ._update_favorite_scope_button_text ()
self .log_signal .emit (f" Favorite download scope changed to: '{self .favorite_download_scope }'")
def _check_for_updates(self):
"""Phase 1 of Update: Fetches all posts, compares, and prompts the user for confirmation."""
self.log_signal.emit("🔄 Checking for updates...")
update_url = self.active_update_profile['creator_url'][0]
processed_ids_from_profile = set(self.active_update_profile['processed_post_ids'])
self.log_signal.emit(f" Checking URL: {update_url}")
self.set_ui_enabled(False)
self.progress_label.setText(self._tr("progress_fetching_all_posts", "Progress: Fetching all post pages..."))
QCoreApplication.processEvents()
try:
post_generator = download_from_api(
api_url_input=update_url,
logger=lambda msg: None, # Suppress noisy logs during check
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=self.use_cookie_checkbox.isChecked(),
cookie_text=self.cookie_text_input.text(),
selected_cookie_file=self.selected_cookie_filepath,
app_base_dir=self.app_base_dir,
processed_post_ids=processed_ids_from_profile
)
all_posts_from_api = [post for batch in post_generator for post in batch]
except Exception as e:
self.log_signal.emit(f"❌ Failed to fetch posts during update check: {e}")
self.download_finished(0, 0, False, [])
return
self.log_signal.emit(f" Fetched a total of {len(all_posts_from_api)} posts from the server.")
self.new_posts_for_update = [post for post in all_posts_from_api if post.get('id') not in processed_ids_from_profile]
if not self.new_posts_for_update:
self.log_signal.emit("✅ Creator is up to date! No new posts found.")
QMessageBox.information(self, "Up to Date", "No new posts were found for this creator.")
self._clear_update_selection()
self.set_ui_enabled(True)
self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle"))
return
self.log_signal.emit(f" Found {len(self.new_posts_for_update)} new post(s). Waiting for user confirmation to download.")
self.progress_label.setText(f"Found {len(self.new_posts_for_update)} new post(s). Ready to download.")
self._update_button_states_and_connections()
def _start_confirmed_update_download(self):
"""Phase 2 of Update: Starts the download of posts found during the check."""
self.log_signal.emit(f"✅ User confirmed. Starting download for {len(self.new_posts_for_update)} new post(s).")
self.main_log_output.clear()
from src.config.constants import FOLDER_NAME_STOP_WORDS
update_url = self.active_update_profile['creator_url'][0]
service, user_id, _ = extract_post_info(update_url)
# --- FIX: Use the BASE download path, not the creator-specific one ---
# Get the base path from the UI (e.g., "E:/Kemono"). The worker will create subfolders inside this.
base_download_dir_from_ui = self.dir_input.text().strip()
self.log_signal.emit(f" Update session will save to base folder: {base_download_dir_from_ui}")
# --- END FIX ---
raw_character_filters_text = self.character_input.text().strip()
parsed_character_filter_objects = self._parse_character_filters(raw_character_filters_text)
# --- FIX: Set paths to mimic a normal download, allowing the worker to create subfolders ---
# 'download_root' is the base directory.
# 'override_output_dir' is None, which allows the worker to use its own folder logic.
args_template = {
'api_url_input': update_url,
'download_root': base_download_dir_from_ui, # Corrected: Use the BASE path
'override_output_dir': None, # Corrected: Set to None to allow subfolder logic
'known_names': list(KNOWN_NAMES),
'filter_character_list': parsed_character_filter_objects,
'emitter': self.worker_to_gui_queue,
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
'filter_mode': self.get_filter_mode(),
'skip_zip': self.skip_zip_checkbox.isChecked(),
'use_subfolders': self.use_subfolders_checkbox.isChecked(),
'use_post_subfolders': self.use_subfolder_per_post_checkbox.isChecked(),
'target_post_id_from_initial_url': None,
'custom_folder_name': self.custom_folder_input.text().strip(),
'compress_images': self.compress_images_checkbox.isChecked(),
'download_thumbnails': self.download_thumbnails_checkbox.isChecked(),
'service': service, 'user_id': user_id, 'pause_event': self.pause_event,
'cancellation_event': self.cancellation_event,
'downloaded_files': self.downloaded_files,
'downloaded_file_hashes': self.downloaded_file_hashes,
'downloaded_files_lock': self.downloaded_files_lock,
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
'skip_words_list': [word.strip().lower() for word in self.skip_words_input.text().strip().split(',') if word.strip()],
'skip_words_scope': self.get_skip_words_scope(),
'show_external_links': self.external_links_checkbox.isChecked(),
'extract_links_only': self.radio_only_links.isChecked(),
'skip_current_file_flag': None,
'manga_mode_active': self.manga_mode_checkbox.isChecked(),
'manga_filename_style': self.manga_filename_style,
'char_filter_scope': self.get_char_filter_scope(),
'remove_from_filename_words_list': [word.strip() for word in self.remove_from_filename_input.text().strip().split(',') if word.strip()],
'allow_multipart_download': self.allow_multipart_download_setting,
'cookie_text': self.cookie_text_input.text(),
'use_cookie': self.use_cookie_checkbox.isChecked(),
'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir,
'manga_date_prefix': self.manga_date_prefix_input.text().strip(),
'manga_date_file_counter_ref': None,
'scan_content_for_images': self.scan_content_images_checkbox.isChecked(),
'creator_download_folder_ignore_words': None,
'manga_global_file_counter_ref': None,
'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked(),
'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked(),
'keep_duplicates_mode': self.keep_duplicates_mode,
'keep_duplicates_limit': self.keep_duplicates_limit,
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'session_file_path': self.session_file_path,
'session_lock': self.session_lock,
'text_only_scope': self.more_filter_scope,
'text_export_format': self.text_export_format,
'single_pdf_mode': self.single_pdf_setting,
'project_root_dir': self.app_base_dir,
'processed_post_ids': list(self.active_update_profile['processed_post_ids'])
}
num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1
self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='UpdateWorker_')
self.total_posts_to_process = len(self.new_posts_for_update)
self.processed_posts_count = 0
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
for post_data in self.new_posts_for_update:
self._submit_post_to_worker_pool(
post_data, args_template, 1, self.worker_to_gui_queue, ppw_expected_keys, {}
)
return True
def _show_empty_popup (self ):
"""Creates and shows the empty popup dialog."""
if self.is_restore_pending:
@@ -4782,7 +5118,21 @@ class DownloaderApp (QWidget ):
return
dialog = EmptyPopupDialog(self.app_base_dir, self)
if dialog.exec_() == QDialog.Accepted:
if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
if dialog.update_profile_data:
self.active_update_profile = dialog.update_profile_data
self.link_input.setText(dialog.update_creator_name)
self.favorite_download_queue.clear()
if 'settings' in self.active_update_profile:
self.log_signal.emit(f" Applying saved settings from '{dialog.update_creator_name}' profile...")
self._load_ui_from_settings_dict(self.active_update_profile['settings'])
self.log_signal.emit(" Settings restored.")
self.log_signal.emit(f" Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
self._update_button_states_and_connections()
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None
self.favorite_download_queue.clear()
for creator_data in dialog.selected_creators_for_queue:

View File

@@ -239,16 +239,23 @@ def setup_ui(main_app):
checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(True)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- REORDERED CHECKBOXES ---
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
main_app.use_subfolder_per_post_checkbox.setChecked(True)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(False)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- END REORDER ---
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit()
@@ -380,7 +387,7 @@ def setup_ui(main_app):
main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False)
log_title_layout.addWidget(main_app.link_search_input)
main_app.link_search_button = QPushButton("🔍")
main_app.link_search_button = QPushButton("<EFBFBD>")
main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button)