This commit is contained in:
Yuvi9587
2025-07-16 09:02:47 -07:00
parent 539e76aa9e
commit f303b8b020
6 changed files with 472 additions and 171 deletions

View File

@@ -0,0 +1,122 @@
# KeepDuplicatesDialog.py
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
class KeepDuplicatesDialog(QDialog):
"""A dialog to choose the duplicate handling method, with a limit option."""
def __init__(self, current_mode, current_limit, parent=None):
super().__init__(parent)
self.parent_app = parent
self.selected_mode = current_mode
self.limit = current_limit
self._init_ui()
self._retranslate_ui()
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self)
# Set the initial state based on current settings
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
else:
self.radio_skip_by_hash.setChecked(True)
self.limit_input.setEnabled(False)
def _init_ui(self):
"""Initializes the UI components."""
main_layout = QVBoxLayout(self)
info_label = QLabel()
info_label.setWordWrap(True)
main_layout.addWidget(info_label)
options_group = QGroupBox()
options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self)
# --- Skip by Hash Option ---
self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash)
# --- Keep Everything Option with Limit Input ---
keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything)
keep_everything_layout.addWidget(self.radio_keep_everything)
keep_everything_layout.addStretch(1)
self.limit_label = QLabel()
self.limit_input = QLineEdit()
self.limit_input.setValidator(QIntValidator(0, 99))
self.limit_input.setFixedWidth(50)
keep_everything_layout.addWidget(self.limit_label)
keep_everything_layout.addWidget(self.limit_input)
options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group)
# --- OK and Cancel buttons ---
button_layout = QHBoxLayout()
self.ok_button = QPushButton()
self.cancel_button = QPushButton()
button_layout.addStretch(1)
button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
# --- Connections ---
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

View File

@@ -1,14 +1,35 @@
# src/ui/dialogs/SupportDialog.py
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
# --- Standard Library Imports ---
import sys
import os
# Assuming execution from project root, so we can import from utils
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox, QGridLayout
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QPixmap
# --- Local Application Imports ---
from ...utils.resolution import get_dark_theme
# --- Helper function for robust asset loading ---
def get_asset_path(filename):
"""
Gets the absolute path to a file in the assets folder,
handling both development and frozen (PyInstaller) environments.
"""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
base_path = sys._MEIPASS
else:
# Running in a normal Python environment from src/ui/dialogs/
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base_path, 'assets', filename)
class SupportDialog(QDialog):
"""
A dialog to show support and donation options.
@@ -17,11 +38,16 @@ class SupportDialog(QDialog):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(400)
self.setMinimumWidth(450)
self._init_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
# Main layout
layout = QVBoxLayout(self)
layout.setSpacing(15)
main_layout = QVBoxLayout(self)
main_layout.setSpacing(15)
# Title Label
title_label = QLabel("Thank You for Your Support!")
@@ -30,7 +56,7 @@ class SupportDialog(QDialog):
font.setBold(True)
title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter)
layout.addWidget(title_label)
main_layout.addWidget(title_label)
# Informational Text
info_label = QLabel(
@@ -39,50 +65,86 @@ class SupportDialog(QDialog):
)
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label)
main_layout.addWidget(info_label)
# Separator
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line)
main_layout.addWidget(line)
# Donation Options
options_layout = QVBoxLayout()
options_layout.setSpacing(10)
# --- Donation Options Layout (using a grid for icons and text) ---
options_layout = QGridLayout()
options_layout.setSpacing(18)
options_layout.setColumnStretch(0, 1) # Add stretch to center the content horizontally
options_layout.setColumnStretch(3, 1)
link_font = self.font()
link_font.setPointSize(12)
link_font.setBold(True)
scale = getattr(self.parent_app, 'scale_factor', 1.0)
icon_size = int(32 * scale)
# --- Ko-fi ---
kofi_label = QLabel(
kofi_icon_label = QLabel()
kofi_pixmap = QPixmap(get_asset_path("kofi.png"))
if not kofi_pixmap.isNull():
kofi_icon_label.setPixmap(kofi_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
kofi_text_label = QLabel(
'<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">'
'☕ Buy me a Ko-fi'
'</a>'
)
kofi_label.setOpenExternalLinks(True)
kofi_label.setAlignment(Qt.AlignCenter)
font.setPointSize(12)
kofi_label.setFont(font)
options_layout.addWidget(kofi_label)
kofi_text_label.setOpenExternalLinks(True)
kofi_text_label.setFont(link_font)
options_layout.addWidget(kofi_icon_label, 0, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(kofi_text_label, 0, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- GitHub Sponsors ---
github_label = QLabel(
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #C9D1D9; text-decoration: none;">'
github_icon_label = QLabel()
github_pixmap = QPixmap(get_asset_path("github_sponsors.png"))
if not github_pixmap.isNull():
github_icon_label.setPixmap(github_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
github_text_label = QLabel(
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #EA4AAA; text-decoration: none;">'
'💜 Sponsor on GitHub'
'</a>'
)
github_label.setOpenExternalLinks(True)
github_label.setAlignment(Qt.AlignCenter)
github_label.setFont(font)
options_layout.addWidget(github_label)
github_text_label.setOpenExternalLinks(True)
github_text_label.setFont(link_font)
layout.addLayout(options_layout)
options_layout.addWidget(github_icon_label, 1, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(github_text_label, 1, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- Buy Me a Coffee (New) ---
bmac_icon_label = QLabel()
bmac_pixmap = QPixmap(get_asset_path("bmac.png"))
if not bmac_pixmap.isNull():
bmac_icon_label.setPixmap(bmac_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
bmac_text_label = QLabel(
'<a href="https://buymeacoffee.com/yuvi9587" style="color: #FFDD00; text-decoration: none;">'
'🍺 Buy Me a Coffee'
'</a>'
)
bmac_text_label.setOpenExternalLinks(True)
bmac_text_label.setFont(link_font)
options_layout.addWidget(bmac_icon_label, 2, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(bmac_text_label, 2, 2, Qt.AlignLeft | Qt.AlignVCenter)
main_layout.addLayout(options_layout)
# Close Button
self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box)
main_layout.addWidget(self.button_box)
self.setLayout(layout)
self._apply_theme()
self.setLayout(main_layout)
def _apply_theme(self):
"""Applies the current theme from the parent application."""
@@ -90,4 +152,4 @@ class SupportDialog(QDialog):
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")
self.setStyleSheet("")

View File

@@ -12,7 +12,7 @@ import subprocess
import datetime
import requests
import unicodedata
from collections import deque
from collections import deque, defaultdict
import threading
from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError
from urllib .parse import urlparse
@@ -57,6 +57,7 @@ from .dialogs.ConfirmAddAllDialog import ConfirmAddAllDialog
from .dialogs.MoreOptionsDialog import MoreOptionsDialog
from .dialogs.SinglePDF import create_single_pdf_from_content
from .dialogs.SupportDialog import SupportDialog
from .dialogs.KeepDuplicatesDialog import KeepDuplicatesDialog
class DynamicFilterHolder:
"""A thread-safe class to hold and update character filters during a download."""
@@ -223,6 +224,10 @@ class DownloaderApp (QWidget ):
self.more_filter_scope = None
self.text_export_format = 'pdf'
self.single_pdf_setting = False
self.keep_duplicates_mode = DUPLICATE_HANDLING_HASH
self.keep_duplicates_limit = 0 # 0 means no limit
self.downloaded_hash_counts = defaultdict(int)
self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = []
print(f" Known.txt will be loaded/saved at: {self.config_file}")
@@ -695,6 +700,8 @@ class DownloaderApp (QWidget ):
self .cookie_text_input .textChanged .connect (self ._handle_cookie_text_manual_change )
if hasattr (self ,'download_thumbnails_checkbox'):
self .download_thumbnails_checkbox .toggled .connect (self ._handle_thumbnail_mode_change )
if hasattr(self, 'keep_duplicates_checkbox'):
self.keep_duplicates_checkbox.toggled.connect(self._handle_keep_duplicates_toggled)
self .gui_update_timer .timeout .connect (self ._process_worker_queue )
self .gui_update_timer .start (100 )
self .log_signal .connect (self .handle_main_log )
@@ -2628,7 +2635,8 @@ class DownloaderApp (QWidget ):
self .file_progress_label .setText ("")
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False):
self.is_finishing = False
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
self._clear_stale_temp_files()
@@ -3071,7 +3079,6 @@ class DownloaderApp (QWidget ):
else:
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)")
pr_log = "All"
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()
@@ -3192,7 +3199,11 @@ class DownloaderApp (QWidget ):
'session_lock': self.session_lock,
'creator_download_folder_ignore_words': creator_folder_ignore_words_for_run,
'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked() if hasattr(self, 'date_prefix_checkbox') else False,
'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked() if hasattr(self, 'keep_duplicates_checkbox') else False,
'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,
'skip_current_file_flag': None,
'processed_post_ids': processed_post_ids_for_restore,
}
@@ -3222,6 +3233,8 @@ class DownloaderApp (QWidget ):
'allow_multipart_download', 'use_cookie', 'cookie_text', 'app_base_dir', 'selected_cookie_file', 'override_output_dir', 'project_root_dir',
'text_only_scope', 'text_export_format',
'single_pdf_mode',
'use_date_prefix_for_subfolder','keep_in_post_duplicates', 'keep_duplicates_mode',
'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock',
'processed_post_ids'
]
args_template['skip_current_file_flag'] = None
@@ -3494,9 +3507,9 @@ class DownloaderApp (QWidget ):
'skip_current_file_flag','manga_date_file_counter_ref','scan_content_for_images',
'manga_mode_active','manga_filename_style','manga_date_prefix','text_only_scope',
'text_export_format', 'single_pdf_mode',
'use_date_prefix_for_subfolder','keep_in_post_duplicates','manga_global_file_counter_ref',
'use_date_prefix_for_subfolder','keep_in_post_duplicates','keep_duplicates_mode','manga_global_file_counter_ref',
'creator_download_folder_ignore_words','session_file_path','project_root_dir','session_lock',
'processed_post_ids' # This key was missing
'processed_post_ids', 'keep_duplicates_limit', 'downloaded_hash_counts', 'downloaded_hash_counts_lock'
]
num_file_dl_threads_for_each_worker = worker_args_template.get('num_file_threads_for_worker', 1)
@@ -3537,7 +3550,7 @@ class DownloaderApp (QWidget ):
if permanent:
self.permanently_failed_files_for_dialog.extend(permanent)
self._update_error_button_count() # <-- THIS IS THE FIX
self._update_error_button_count()
# Other result handling
if history_data: self._add_to_history_candidates(history_data)
@@ -3676,7 +3689,7 @@ class DownloaderApp (QWidget ):
self .external_links_checkbox ,self .manga_mode_checkbox ,self .manga_rename_toggle_button ,self .use_cookie_checkbox ,self .cookie_text_input ,self .cookie_browse_button ,
self .multipart_toggle_button ,self .radio_only_audio ,
self .character_search_input ,self .new_char_input ,self .add_char_button ,self .add_to_filter_button ,self .delete_char_button ,
self .reset_button
self .reset_button, self.radio_more, self.keep_duplicates_checkbox
]
widgets_to_enable_on_pause =self ._get_configurable_widgets_on_pause ()
@@ -4063,6 +4076,42 @@ class DownloaderApp (QWidget ):
self .set_ui_enabled (True )
self .cancellation_message_logged_this_session =False
def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is checked."""
if checked:
dialog = KeepDuplicatesDialog(self.keep_duplicates_mode, self.keep_duplicates_limit, self)
if dialog.exec_() == QDialog.Accepted:
options = dialog.get_selected_options()
self.keep_duplicates_mode = options["mode"]
self.keep_duplicates_limit = options["limit"]
limit_text = f"with a limit of {self.keep_duplicates_limit}" if self.keep_duplicates_limit > 0 else "with no limit"
self.log_signal.emit(f" Duplicate handling mode set to: '{self.keep_duplicates_mode}' {limit_text}.")
self.log_signal.emit(f"")
self.log_signal.emit(f"")
# Log warning only after the confirmation and only if the specific mode is selected
if self.keep_duplicates_mode == DUPLICATE_HANDLING_KEEP_ALL:
self._log_keep_everything_warning()
else:
self.keep_duplicates_checkbox.setChecked(False)
else:
self.keep_duplicates_mode = DUPLICATE_HANDLING_HASH
self.keep_duplicates_limit = 0
self.log_signal.emit(" 'Keep Duplicates' disabled. Reverted to default hash checking.")
def _log_keep_everything_warning(self):
"""Logs a formatted warning when the 'Keep Everything' mode is selected."""
warning_html = (
f'{HTML_PREFIX}'
'<h2 style="margin-top: 8px; margin-bottom: 4px; font-weight: bold;">⚠️ ATTENTION: "Keep Everything" Enabled</h2>'
'<h3><p style="margin-top: 0; margin-bottom: 4px;">This mode will download every single file from the API response for a post,</p>'
'<p style="margin-top: 0; margin-bottom: 4px;">even if they have identical content. This can lead to many redundant files.</p>'
'<p style="margin-top: 0; margin-bottom: 4px;"><b>Recommendation:</b> Consider using the <b>limit feature</b>.</p>'
'<p style="margin-top: 0; margin-bottom: 0;">For example, setting the limit to <b>2</b> will download a file with the same content up to two times.</p></h3>'
)
self.log_signal.emit(warning_html)
def _handle_thumbnail_mode_change (self ,thumbnails_checked ):
"""Handles UI changes when 'Download Thumbnails Only' is toggled."""
@@ -4266,9 +4315,7 @@ 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):
# --- Stop all background tasks and threads ---
if self._is_download_active():
# Try to cancel download thread
if self.download_thread and self.download_thread.isRunning():
self.log_signal.emit("⚠️ Cancelling active download thread for reset...")
self.cancellation_event.set()
@@ -4308,6 +4355,14 @@ class DownloaderApp (QWidget ):
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()
# --- Reset UI and all state ---
self.log_signal.emit("🔄 Resetting application state to defaults...")
@@ -4407,6 +4462,10 @@ class DownloaderApp (QWidget ):
self.use_multithreading_checkbox.setChecked(True)
if self.favorite_mode_checkbox:
self.favorite_mode_checkbox.setChecked(False)
if hasattr(self, 'keep_duplicates_checkbox'):
self.keep_duplicates_checkbox.setChecked(False)
self.external_links_checkbox.setChecked(False)
if self.manga_mode_checkbox:
self.manga_mode_checkbox.setChecked(False)
@@ -4451,7 +4510,6 @@ class DownloaderApp (QWidget ):
if self.pause_event:
self.pause_event.clear()
# Reset extracted/external links state
self.external_link_queue.clear()
self.extracted_links_cache = []
self._is_processing_external_link_queue = False