diff --git a/creators.json b/data/creators.json
similarity index 100%
rename from creators.json
rename to data/creators.json
diff --git a/drive.py b/drive.py
deleted file mode 100644
index 799aa89..0000000
--- a/drive.py
+++ /dev/null
@@ -1,205 +0,0 @@
-from mega import Mega
-import os
-import requests
-import traceback
-from urllib .parse import urlparse ,urlunparse ,parse_qs ,urlencode
-
-try :
- import gdown
- GDOWN_AVAILABLE =True
-except ImportError :
- GDOWN_AVAILABLE =False
-
-def download_mega_file (mega_link ,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, optional): The directory to save the downloaded file.
- Defaults to the current directory.
- logger_func (callable, optional): Function to use for logging. Defaults to print.
- """
- logger_func ("drive.py: download_mega_file called.")
- logger_func (f"drive.py: mega_link='{mega_link}', download_path='{download_path}'")
-
- logger_func ("drive.py: Initializing Mega client (Mega())...")
- try:
- mega_client = Mega()
- except Exception as e_init:
- logger_func(f"drive.py: ERROR during Mega() instantiation: {e_init}")
- traceback.print_exc()
- raise
- logger_func ("drive.py: Mega client initialized. Logging in anonymously (m.login())...")
- try:
- m = mega_client.login()
- except Exception as e_login:
- logger_func(f"drive.py: ERROR during m.login(): {e_login}")
- traceback.print_exc()
- raise
- logger_func ("drive.py: Logged in anonymously.")
-
- logger_func (f"drive.py: Attempting to download from: {mega_link }")
-
- try :
- if not os .path .exists (download_path ):
- logger_func (f"drive.py: Download path '{download_path }' does not exist. Creating it...")
- os .makedirs (download_path ,exist_ok =True )
- logger_func (f"drive.py: Download path ensured: '{download_path }'")
-
- logger_func (f"drive.py: Calling m.download_url for '{mega_link }' to '{download_path }'...")
-
- # The download_url method returns the local file path of the downloaded file.
- # It takes dest_path (directory) and dest_filename (optional).
- # If dest_filename is None, it uses the name from get_public_url_info().
- downloaded_file_path = m.download_url(mega_link, dest_path=download_path, dest_filename=None)
-
- logger_func(f"drive.py: m.download_url returned: {downloaded_file_path}")
-
- if downloaded_file_path and os.path.exists(downloaded_file_path):
- logger_func(f"drive.py: File downloaded successfully! Saved as: {downloaded_file_path}")
- # Optional: Verify size if possible, but get_public_url_info is another network call
- # and might be redundant or problematic if the download itself worked.
- elif downloaded_file_path:
- logger_func(f"drive.py: m.download_url returned a path '{downloaded_file_path}', but it does not exist on disk. Download may have failed silently or path is incorrect.")
- raise Exception(f"Mega download_url returned path '{downloaded_file_path}' which was not found.")
- else :
- logger_func ("drive.py: Download failed. m.download_url did not return a valid file path.")
- raise Exception ("Mega download_url did not return a file path or failed.")
-
- except PermissionError as e:
- logger_func(f"drive.py: PermissionError: {e}. Denied to write to '{download_path}'. Please check permissions.")
- raise
- except FileNotFoundError as e:
- logger_func(f"drive.py: FileNotFoundError: {e}. The path '{download_path}' is invalid.")
- raise
- except requests.exceptions.ConnectionError as e: # More specific for network
- logger_func(f"drive.py: requests.exceptions.ConnectionError: {e}. Network problem during Mega operation.")
- raise
- except requests.exceptions.RequestException as e: # General requests error
- logger_func(f"drive.py: requests.exceptions.RequestException: {e} during request to Mega.")
- raise
- except Exception as e: # Catch-all for other errors from mega.py or os calls
- logger_func(f"drive.py: An unexpected error occurred during Mega download: {e}")
- traceback.print_exc() # Print full traceback for unexpected errors
- raise
-
-def download_gdrive_file (gdrive_link ,download_path =".",logger_func =print ):
- """
- Downloads a file from a public Google Drive link.
-
- Args:
- gdrive_link (str): The public Google Drive link to the file.
- download_path (str, optional): The directory to save the downloaded file.
- Defaults to the current directory.
- logger_func (callable, optional): Function to use for logging. Defaults to print.
- """
- 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. Please install it: pip install gdown")
-
- logger_func (f"Attempting to download from Google Drive: {gdrive_link }")
- try :
- if not os .path .exists (download_path ):
- logger_func (f"Download path '{download_path }' does not exist. Creating it...")
- os .makedirs (download_path ,exist_ok =True )
-
- logger_func (f"Starting Google Drive download to '{download_path }'...")
-
- output_file_path =gdown .download (gdrive_link ,output =download_path ,quiet =False ,fuzzy =True )
-
- if output_file_path and os .path .exists (os .path .join (download_path ,os .path .basename (output_file_path ))):
- logger_func (f"✅ Google Drive file downloaded successfully: {output_file_path }")
- elif output_file_path :
- full_path_check =os .path .join (download_path ,output_file_path )
- if os .path .exists (full_path_check ):
- logger_func (f"✅ Google Drive file downloaded successfully: {full_path_check }")
- else :
- logger_func (f"⚠️ Google Drive download finished, gdown returned '{output_file_path }', but file not found at expected location.")
- logger_func (f" Please check '{download_path }' for the downloaded file, it might have a different name than expected by gdown's return.")
-
- files_in_dest =[f for f in os .listdir (download_path )if os .path .isfile (os .path .join (download_path ,f ))]
- if len (files_in_dest )==1 :
- logger_func (f" Found one file in destination: {os .path .join (download_path ,files_in_dest [0 ])}. Assuming this is it.")
- elif len (files_in_dest )>1 and output_file_path in files_in_dest :
- logger_func (f" Confirmed file '{output_file_path }' exists in '{download_path }'.")
- else :
- raise Exception (f"gdown download failed or file not found. Returned: {output_file_path }")
- else :
- logger_func ("❌ Google Drive download failed. gdown did not return an output path.")
- raise Exception ("gdown download failed.")
-
- except PermissionError :
- logger_func (f"❌ Error: Permission denied to write to '{download_path }'. Please check permissions.")
- raise
- except Exception as e :
- logger_func (f"❌ An error occurred during Google Drive download: {e }")
- traceback .print_exc ()
- raise
-
-def _get_filename_from_headers (headers ):
- cd =headers .get ('content-disposition')
- if not cd :
- return None
- fname_match =re .findall ('filename="?([^"]+)"?',cd )
- if fname_match :
- return fname_match [0 ].strip ()
- return None
-
-def download_dropbox_file (dropbox_link ,download_path =".",logger_func =print ):
- """
- Downloads a file from a public Dropbox link.
-
- Args:
- dropbox_link (str): The public Dropbox link to the file.
- download_path (str, optional): The directory to save the downloaded file.
- Defaults to the current directory.
- logger_func (callable, optional): Function to use for logging. Defaults to print.
- """
- logger_func (f"Attempting to download from Dropbox: {dropbox_link }")
-
-
- parsed_url =urlparse (dropbox_link )
- query_params =parse_qs (parsed_url .query )
- query_params ['dl']=['1']
- new_query =urlencode (query_params ,doseq =True )
- direct_download_url =urlunparse (parsed_url ._replace (query =new_query ))
-
- logger_func (f" Using direct download URL: {direct_download_url }")
-
- try :
- if not os .path .exists (download_path ):
- logger_func (f"Download path '{download_path }' does not exist. Creating it...")
- os .makedirs (download_path ,exist_ok =True )
-
- with requests .get (direct_download_url ,stream =True ,allow_redirects =True ,timeout =(10 ,300 ))as r :
- r .raise_for_status ()
- filename =_get_filename_from_headers (r .headers )or os .path .basename (urlparse (dropbox_link ).path )or "dropbox_downloaded_file"
-
- filename =re .sub (r'[<>:"/\\|?*]','_',filename )
- full_save_path =os .path .join (download_path ,filename )
- logger_func (f"Starting Dropbox download of '{filename }' to '{full_save_path }'...")
- with open (full_save_path ,'wb')as f :
- for chunk in r .iter_content (chunk_size =8192 ):
- f .write (chunk )
- logger_func (f"✅ Dropbox file downloaded successfully: {full_save_path }")
- except Exception as e :
- logger_func (f"❌ An error occurred during Dropbox download: {e }")
- traceback .print_exc ()
- raise
-
-if __name__ =="__main__":
-
- mega_file_link ="https://mega.nz/file/03oRjBQT#Tcbp5sQVIyPbdmv8sLgbb9Lf9AZvZLdKRSQiuXkNW0k"
-
- if not mega_file_link .startswith ("https://mega.nz/file/"):
- print ("Invalid Mega file link format. It should start with 'https://mega.nz/file/'.")
- else :
-
-
- script_dir =os .path .dirname (os .path .abspath (__file__ ))
- download_directory =os .path .join (script_dir ,"mega_downloads")
-
- print (f"Files will be downloaded to: {download_directory }")
- download_mega_file (mega_file_link ,download_directory ,logger_func =print )
diff --git a/main.py b/main.py
index 83d9fa2..a954c1f 100644
--- a/main.py
+++ b/main.py
@@ -1,8951 +1,115 @@
-import sys
-import os
-import time
-import requests
-import re
-import threading
-import json
-import queue
-import hashlib
-import http .client
-import traceback
-import html
-import subprocess
-import datetime
-import random
-from collections import deque
-import unicodedata
-from concurrent .futures import ThreadPoolExecutor ,CancelledError ,Future
+# --- Standard Library Imports ---
+import sys
+import os
+import time
+import traceback
-from PyQt5 .QtGui import (
-QIcon ,
-QIntValidator ,
-QDesktopServices
-)
-from PyQt5 .QtWidgets import (
-QApplication ,QWidget ,QLabel ,QLineEdit ,QTextEdit ,QPushButton ,
-QVBoxLayout ,QHBoxLayout ,QFileDialog ,QMessageBox ,QListWidget ,QRadioButton ,QButtonGroup ,QCheckBox ,QSplitter ,QComboBox ,QGroupBox ,
-QDialog ,QStackedWidget ,QScrollArea ,QListWidgetItem ,QSizePolicy ,QProgressBar ,
-QAbstractItemView ,
-QFrame ,
-QAbstractButton
-)
-from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess
-from urllib .parse import urlparse
+# --- PyQt5 Imports ---
+from PyQt5.QtWidgets import QApplication, QDialog
+from PyQt5.QtCore import QCoreApplication
-try :
- from PIL import Image
-except ImportError :
- Image =None
-
-from io import BytesIO
-
-try :
- print ("Attempting to import from downloader_utils...")
- from downloader_utils import (
- KNOWN_NAMES ,
- clean_folder_name ,
- extract_post_info ,
- download_from_api ,
- PostProcessorSignals ,
- prepare_cookies_for_request ,
- PostProcessorWorker ,
- DownloadThread as BackendDownloadThread ,
- SKIP_SCOPE_FILES ,
- SKIP_SCOPE_POSTS ,
- SKIP_SCOPE_BOTH ,
- CHAR_SCOPE_TITLE ,
- CHAR_SCOPE_FILES ,
- CHAR_SCOPE_BOTH ,
- CHAR_SCOPE_COMMENTS ,
- FILE_DOWNLOAD_STATUS_SUCCESS ,
- FILE_DOWNLOAD_STATUS_SKIPPED ,
- FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER ,
- STYLE_DATE_BASED ,
- STYLE_DATE_POST_TITLE ,
- STYLE_POST_TITLE_GLOBAL_NUMBERING ,
- CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS ,
- download_mega_file as drive_download_mega_file ,
- download_gdrive_file ,
- download_dropbox_file
- )
- 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 }")
- print (f"--- Check downloader_utils.py for syntax errors or missing dependencies. ---")
- KNOWN_NAMES =[]
- PostProcessorWorker =object
- class _MockPostProcessorSignals (QObject ):
- progress_signal =pyqtSignal (str )
- file_download_status_signal =pyqtSignal (bool )
- external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
- file_progress_signal =pyqtSignal (str ,object )
- missed_character_post_signal =pyqtSignal (str ,str )
- def __init__ (self ,parent =None ):
- super ().__init__ (parent )
- print ("WARNING: Using MOCK PostProcessorSignals due to import error from downloader_utils.py. Some functionalities might be impaired.")
- PostProcessorSignals =_MockPostProcessorSignals
- 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 []
- SKIP_SCOPE_FILES ="files"
- SKIP_SCOPE_POSTS ="posts"
- SKIP_SCOPE_BOTH ="both"
- CHAR_SCOPE_TITLE ="title"
- CHAR_SCOPE_FILES ="files"
- CHAR_SCOPE_BOTH ="both"
- CHAR_SCOPE_COMMENTS ="comments"
- FILE_DOWNLOAD_STATUS_SUCCESS ="success"
- FILE_DOWNLOAD_STATUS_SKIPPED ="skipped"
- FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER ="failed_retry_later"
- STYLE_DATE_BASED ="date_based"
- STYLE_DATE_POST_TITLE ="date_post_title"
- STYLE_POST_TITLE_GLOBAL_NUMBERING ="post_title_global_numbering"
- CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS =set ()
- def drive_download_mega_file (*args ,**kwargs ):print ("drive_download_mega_file (stub)");pass
- def download_gdrive_file (*args ,**kwargs ):print ("download_gdrive_file (stub)");pass
- def download_dropbox_file (*args ,**kwargs ):print ("download_dropbox_file (stub)");pass
-
-except Exception as e :
- print (f"--- UNEXPECTED IMPORT ERROR ---")
- print (f"An unexpected error occurred during import: {e }")
- traceback .print_exc ()
- print (f"-----------------------------",file =sys .stderr )
- sys .exit (1 )
-try :
- from languages import get_translation
-except ImportError :
- print ("Failed to import get_translation from languages.py. Dialog translations will not work.")
- print (f"-----------------------------",file =sys .stderr )
- sys .exit (1 )
+# --- Local Application Imports ---
+# These imports reflect the new, organized project structure.
+from src.ui.main_window import DownloaderApp
+from src.ui.dialogs.TourDialog import TourDialog
+from src.config.constants import CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN
-_app_icon_cache =None
-
-def get_app_icon_object ():
+def handle_uncaught_exception(exc_type, exc_value, exc_traceback):
"""
- Loads and caches the application icon.
- Returns a QIcon object.
+ Handles uncaught exceptions by logging them to a file for easier debugging,
+ especially for bundled applications.
"""
- global _app_icon_cache
- if _app_icon_cache is not None and not _app_icon_cache .isNull ():
- return _app_icon_cache
-
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
- base_dir =sys ._MEIPASS
- else :
- base_dir =os .path .dirname (os .path .abspath (__file__ ))
-
- icon_path =os .path .join (base_dir ,'assets','Kemono.ico')
-
- if os .path .exists (icon_path ):
- _app_icon_cache =QIcon (icon_path )
- if _app_icon_cache .isNull ():
- print (f"Warning: QIcon created from '{icon_path }' is null. Icon might be invalid.")
- _app_icon_cache =QIcon ()
- else :
- print (f"Warning: Application icon 'assets/Kemono.ico' not found at {icon_path } (in get_app_icon_object)")
- _app_icon_cache =QIcon ()
-
- return _app_icon_cache
-
-MAX_THREADS =200
-RECOMMENDED_MAX_THREADS =50
-MAX_FILE_THREADS_PER_POST_OR_WORKER =10
-POST_WORKER_BATCH_THRESHOLD =30
-POST_WORKER_NUM_BATCHES =4
-SOFT_WARNING_THREAD_THRESHOLD =40
-POST_WORKER_BATCH_DELAY_SECONDS =2.5
-MAX_POST_WORKERS_WHEN_COMMENT_FILTERING =3
-
-HTML_PREFIX =""
-
-CONFIG_ORGANIZATION_NAME ="KemonoDownloader"
-CONFIG_APP_NAME_MAIN ="ApplicationSettings"
-MANGA_FILENAME_STYLE_KEY ="mangaFilenameStyleV1"
-STYLE_POST_TITLE ="post_title"
-STYLE_ORIGINAL_NAME ="original_name"
-STYLE_DATE_BASED ="date_based"
-STYLE_POST_TITLE_GLOBAL_NUMBERING =STYLE_POST_TITLE_GLOBAL_NUMBERING
-SKIP_WORDS_SCOPE_KEY ="skipWordsScopeV1"
-ALLOW_MULTIPART_DOWNLOAD_KEY ="allowMultipartDownloadV1"
-
-USE_COOKIE_KEY ="useCookieV1"
-COOKIE_TEXT_KEY ="cookieTextV1"
-CHAR_FILTER_SCOPE_KEY ="charFilterScopeV1"
-THEME_KEY ="currentThemeV2"
-SCAN_CONTENT_IMAGES_KEY ="scanContentForImagesV1"
-LANGUAGE_KEY ="currentLanguageV1"
-DOWNLOAD_LOCATION_KEY ="downloadLocationV1"
-
-CONFIRM_ADD_ALL_ACCEPTED =1
-FAVORITE_SCOPE_SELECTED_LOCATION ="selected_location"
-FAVORITE_SCOPE_ARTIST_FOLDERS ="artist_folders"
-CONFIRM_ADD_ALL_SKIP_ADDING =2
-CONFIRM_ADD_ALL_CANCEL_DOWNLOAD =3
-LOG_DISPLAY_LINKS ="links"
-LOG_DISPLAY_DOWNLOAD_PROGRESS ="download_progress"
-
-from collections import defaultdict
-class DownloadExtractedLinksDialog (QDialog ):
- """A dialog to select and initiate download for extracted supported links."""
-
- download_requested =pyqtSignal (list )
-
-
- def __init__ (self ,links_data ,parent_app ,parent =None ):
-
-
- super ().__init__ (parent )
- self .links_data =links_data
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
-
-
- if parent :
- parent_width =parent .width ()
- parent_height =parent .height ()
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
-
- base_min_w ,base_min_h =500 ,400
- 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 )
- self .resize (max (int (parent_width *0.6 *scale_factor ),scaled_min_w ),max (int (parent_height *0.7 *scale_factor ),scaled_min_h ))
-
-
-
- layout =QVBoxLayout (self )
- self .main_info_label =QLabel ()
- self .main_info_label .setAlignment (Qt .AlignHCenter |Qt .AlignTop )
- self .main_info_label .setWordWrap (True )
- layout .addWidget (self .main_info_label )
-
- self .links_list_widget =QListWidget ()
- self .links_list_widget .setSelectionMode (QAbstractItemView .NoSelection )
-
- grouped_links =defaultdict (list )
- for link_info_item in self .links_data :
- post_title_for_group =link_info_item .get ('title','Untitled Post')
- grouped_links [post_title_for_group ].append (link_info_item )
-
-
- sorted_post_titles =sorted (grouped_links .keys (),key =lambda x :x .lower ())
-
- for post_title_key in sorted_post_titles :
-
- header_item =QListWidgetItem (f"{post_title_key }")
- header_item .setFlags (Qt .NoItemFlags )
- font =header_item .font ()
- font .setBold (True )
- font .setPointSize (font .pointSize ()+1 )
- header_item .setFont (font )
- if parent and hasattr (parent ,'current_theme')and parent .current_theme =="dark":
- header_item .setForeground (Qt .cyan )
- else :
- header_item .setForeground (Qt .blue )
- self .links_list_widget .addItem (header_item )
-
- for link_info_data in grouped_links [post_title_key ]:
- platform_display =link_info_data .get ('platform','unknown').upper ()
- display_text =f" [{platform_display }] {link_info_data ['link_text']} ({link_info_data ['url']})"
- item =QListWidgetItem (display_text )
- item .setData (Qt .UserRole ,link_info_data )
- item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
- item .setCheckState (Qt .Checked )
- self .links_list_widget .addItem (item )
-
-
-
-
- layout .addWidget (self .links_list_widget )
-
- button_layout =QHBoxLayout ()
- self .select_all_button =QPushButton ()
- self .select_all_button .clicked .connect (lambda :self ._set_all_items_checked (Qt .Checked ))
- button_layout .addWidget (self .select_all_button )
-
- self .deselect_all_button =QPushButton ()
- self .deselect_all_button .clicked .connect (lambda :self ._set_all_items_checked (Qt .Unchecked ))
- button_layout .addWidget (self .deselect_all_button )
- button_layout .addStretch ()
-
- self .download_button =QPushButton ()
- self .download_button .clicked .connect (self ._handle_download_selected )
- self .download_button .setDefault (True )
- button_layout .addWidget (self .download_button )
-
- self .cancel_button =QPushButton ()
- self .cancel_button .clicked .connect (self .reject )
- button_layout .addWidget (self .cancel_button )
- layout .addLayout (button_layout )
-
- self .parent_app =parent_app
- self ._retranslate_ui ()
-
- if parent and hasattr (parent ,'get_dark_theme')and parent .current_theme =="dark":
- self .setStyleSheet (parent .get_dark_theme ())
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("download_external_links_dialog_title","Download Selected External Links"))
-
- self .main_info_label .setText (self ._tr ("download_external_links_dialog_main_label","Found {count} supported link(s)...").format (count =len (self .links_data )))
- self .select_all_button .setText (self ._tr ("select_all_button_text","Select All"))
- self .deselect_all_button .setText (self ._tr ("deselect_all_button_text","Deselect All"))
- self .download_button .setText (self ._tr ("download_selected_button_text","Download Selected"))
- self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
-
- def _set_all_items_checked (self ,check_state ):
- for i in range (self .links_list_widget .count ()):
- item =self .links_list_widget .item (i )
- if item .flags ()&Qt .ItemIsUserCheckable :
- item .setCheckState (check_state )
-
- def _handle_download_selected (self ):
- selected_links =[]
- for i in range (self .links_list_widget .count ()):
- item =self .links_list_widget .item (i )
-
- if item .flags ()&Qt .ItemIsUserCheckable and item .checkState ()==Qt .Checked and item .data (Qt .UserRole )is not None :
- selected_links .append (item .data (Qt .UserRole ))
- if selected_links :
- self .download_requested .emit (selected_links )
- self .accept ()
- else :
- QMessageBox .information (
- self ,
- self ._tr ("no_selection_title","No Selection"),
- self ._tr ("no_selection_message_links","Please select at least one link to download."))
-
-class ConfirmAddAllDialog (QDialog ):
- """A dialog to confirm adding multiple new names to Known.txt."""
- def __init__ (self ,new_filter_objects_list ,parent_app ,parent =None ):
- super ().__init__ (parent )
- self .parent_app =parent_app
- self .setModal (True )
- self .new_filter_objects_list =new_filter_objects_list
- self .setWindowTitle (self ._tr ("confirm_add_all_dialog_title","Confirm Adding New Names"))
- self .user_choice =CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
- self .setWindowTitle (self ._tr ("confirm_add_all_dialog_title","Confirm Adding New Names"))
-
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
-
- base_min_w ,base_min_h =480 ,350
- 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 )
-
- main_layout =QVBoxLayout (self )
-
- info_label =QLabel (
- "The following new names/groups from your 'Filter by Character(s)' input are not in 'Known.txt'.\n"
- "Adding them can improve folder organization for future downloads.\n\n"
- "Review the list and choose an action:")
-
- self .names_list_widget =QListWidget ()
- for filter_obj in self .new_filter_objects_list :
- item_text =filter_obj ["name"]
- list_item =QListWidgetItem (item_text )
- list_item .setFlags (list_item .flags ()|Qt .ItemIsUserCheckable )
- list_item .setCheckState (Qt .Checked )
- list_item .setData (Qt .UserRole ,filter_obj )
- self .names_list_widget .addItem (list_item )
-
- self .info_label =QLabel ()
- self .info_label .setWordWrap (True )
- main_layout .addWidget (self .info_label )
-
- main_layout .addWidget (self .names_list_widget )
-
- selection_buttons_layout =QHBoxLayout ()
- self .select_all_button =QPushButton ()
- self .select_all_button .clicked .connect (self ._select_all_items )
- selection_buttons_layout .addWidget (self .select_all_button )
-
- self .deselect_all_button =QPushButton ()
- self .deselect_all_button .clicked .connect (self ._deselect_all_items )
- selection_buttons_layout .addWidget (self .deselect_all_button )
- selection_buttons_layout .addStretch ()
- main_layout .addLayout (selection_buttons_layout )
-
-
- buttons_layout =QHBoxLayout ()
-
- self .add_selected_button =QPushButton ()
- self .add_selected_button .clicked .connect (self ._accept_add_selected )
- buttons_layout .addWidget (self .add_selected_button )
-
- self .skip_adding_button =QPushButton ()
- self .skip_adding_button .clicked .connect (self ._reject_skip_adding )
- buttons_layout .addWidget (self .skip_adding_button )
- buttons_layout .addStretch ()
-
- self .cancel_download_button =QPushButton ()
- self .cancel_download_button .clicked .connect (self ._reject_cancel_download )
- buttons_layout .addWidget (self .cancel_download_button )
-
- main_layout .addLayout (buttons_layout )
- self ._retranslate_ui ()
-
- if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (parent .get_dark_theme ())
- self .add_selected_button .setDefault (True )
-
- 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 )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("confirm_add_all_dialog_title","Confirm Adding New Names"))
- self .info_label .setText (self ._tr ("confirm_add_all_info_label","The following new names/groups..."))
- self .select_all_button .setText (self ._tr ("confirm_add_all_select_all_button","Select All"))
- self .deselect_all_button .setText (self ._tr ("confirm_add_all_deselect_all_button","Deselect All"))
- self .add_selected_button .setText (self ._tr ("confirm_add_all_add_selected_button","Add Selected to Known.txt"))
- self .skip_adding_button .setText (self ._tr ("confirm_add_all_skip_adding_button","Skip Adding These"))
- self .cancel_download_button .setText (self ._tr ("confirm_add_all_cancel_download_button","Cancel Download"))
-
- def _select_all_items (self ):
- for i in range (self .names_list_widget .count ()):
- self .names_list_widget .item (i ).setCheckState (Qt .Checked )
-
- def _deselect_all_items (self ):
- for i in range (self .names_list_widget .count ()):
- self .names_list_widget .item (i ).setCheckState (Qt .Unchecked )
-
- def _accept_add_selected (self ):
- selected_objects =[]
- for i in range (self .names_list_widget .count ()):
- item =self .names_list_widget .item (i )
- if item .checkState ()==Qt .Checked :
- filter_obj =item .data (Qt .UserRole )
- if filter_obj :
- selected_objects .append (filter_obj )
-
- self .user_choice =selected_objects
- self .accept ()
-
- def _reject_skip_adding (self ):
- self .user_choice =CONFIRM_ADD_ALL_SKIP_ADDING
- self .reject ()
-
- def _reject_cancel_download (self ):
- self .user_choice =CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
- self .reject ()
-
- def exec_ (self ):
- super ().exec_ ()
- if isinstance (self .user_choice ,list )and not self .user_choice :
- return CONFIRM_ADD_ALL_SKIP_ADDING
- return self .user_choice
-
-class ExportOptionsDialog (QDialog ):
- """Dialog to choose export format for error file links."""
- EXPORT_MODE_LINK_ONLY =1
- EXPORT_MODE_WITH_DETAILS =2
-
- def __init__ (self ,parent_app ,parent =None ):
- super ().__init__ (parent )
- self .parent_app =parent_app
- self .setModal (True )
- self .selected_option =self .EXPORT_MODE_LINK_ONLY
-
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
-
- base_min_w =350
- scaled_min_w =int (base_min_w *scale_factor )
- self .setMinimumWidth (scaled_min_w )
-
- layout =QVBoxLayout (self )
-
- self .description_label =QLabel ()
- layout .addWidget (self .description_label )
-
- self .radio_group =QButtonGroup (self )
-
- self .radio_link_only =QRadioButton ()
-
- self .radio_link_only .setChecked (True )
- self .radio_group .addButton (self .radio_link_only ,self .EXPORT_MODE_LINK_ONLY )
- layout .addWidget (self .radio_link_only )
-
- self .radio_with_details =QRadioButton ()
-
- self .radio_group .addButton (self .radio_with_details ,self .EXPORT_MODE_WITH_DETAILS )
- layout .addWidget (self .radio_with_details )
-
- button_layout =QHBoxLayout ()
- self .export_button =QPushButton ()
- self .export_button .clicked .connect (self ._handle_export )
- self .export_button .setDefault (True )
-
- self .cancel_button =QPushButton ()
- self .cancel_button .clicked .connect (self .reject )
-
- button_layout .addStretch (1 )
- button_layout .addWidget (self .export_button )
- button_layout .addWidget (self .cancel_button )
- layout .addLayout (button_layout )
-
- self ._retranslate_ui ()
-
-
- if self .parent_app and hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
- if hasattr (self .parent_app ,'get_dark_theme'):
- self .setStyleSheet (self .parent_app .get_dark_theme ())
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("export_options_dialog_title","Export Options"))
- self .description_label .setText (self ._tr ("export_options_description_label","Choose the format for exporting error file links:"))
- self .radio_link_only .setText (self ._tr ("export_options_radio_link_only","Link per line (URL only)"))
- self .radio_link_only .setToolTip (self ._tr ("export_options_radio_link_only_tooltip","Exports only the direct download URL..."))
- self .radio_with_details .setText (self ._tr ("export_options_radio_with_details","Export with details (URL [Post, File info])"))
- self .radio_with_details .setToolTip (self ._tr ("export_options_radio_with_details_tooltip","Exports the URL followed by details..."))
- self .export_button .setText (self ._tr ("export_options_export_button","Export"))
- self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
-
- def _handle_export (self ):
- self .selected_option =self .radio_group .checkedId ()
- self .accept ()
-
- def get_selected_option (self ):
- return self .selected_option
-
-class ErrorFilesDialog (QDialog ):
- """Dialog to display files that were skipped due to errors."""
- retry_selected_signal =pyqtSignal (list )
- def __init__ (self ,error_files_info_list ,parent_app ,parent =None ):
- super ().__init__ (parent )
- self .parent_app =parent_app
- self .setModal (True )
- self .error_files =error_files_info_list
-
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
-
- base_min_w ,base_min_h =500 ,300
- 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 )
-
- main_layout =QVBoxLayout (self )
-
- if not self .error_files :
- self .info_label =QLabel ()
- main_layout .addWidget (self .info_label )
- else :
- self .info_label =QLabel ()
- self .info_label .setWordWrap (True )
- main_layout .addWidget (self .info_label )
-
- self .files_list_widget =QListWidget ()
- self .files_list_widget .setSelectionMode (QAbstractItemView .NoSelection )
- for error_info in self .error_files :
- filename =error_info .get ('forced_filename_override',error_info .get ('file_info',{}).get ('name','Unknown Filename'))
- post_title =error_info .get ('post_title','Unknown Post')
- post_id =error_info .get ('original_post_id_for_log','N/A')
- item_text =f"File: {filename }\nFrom Post: '{post_title }' (ID: {post_id })"
- list_item =QListWidgetItem (item_text )
- list_item .setData (Qt .UserRole ,error_info )
- list_item .setFlags (list_item .flags ()|Qt .ItemIsUserCheckable )
- list_item .setCheckState (Qt .Unchecked )
- self .files_list_widget .addItem (list_item )
- main_layout .addWidget (self .files_list_widget )
-
- buttons_layout =QHBoxLayout ()
- self .select_all_button =QPushButton ()
- self .select_all_button .clicked .connect (self ._select_all_items )
- buttons_layout .addWidget (self .select_all_button )
-
- self .retry_button =QPushButton ()
- self .retry_button .clicked .connect (self ._handle_retry_selected )
- self .export_button =QPushButton ()
- self .export_button .clicked .connect (self ._handle_export_errors_to_txt )
- buttons_layout .addWidget (self .retry_button )
-
- buttons_layout .addStretch (1 )
- self .ok_button =QPushButton ()
- self .ok_button .clicked .connect (self .accept )
- buttons_layout .addWidget (self .ok_button )
- main_layout .addLayout (buttons_layout )
- buttons_layout .insertWidget (2 ,self .export_button )
-
- self ._retranslate_ui ()
-
- self .select_all_button .setEnabled (bool (self .error_files ))
- self .retry_button .setEnabled (bool (self .error_files ))
- self .export_button .setEnabled (bool (self .error_files ))
-
- if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
- self .ok_button .setDefault (True )
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("error_files_dialog_title","Files Skipped Due to Errors"))
- if not self .error_files :
- self .info_label .setText (self ._tr ("error_files_no_errors_label","No files were recorded..."))
- else :
- self .info_label .setText (self ._tr ("error_files_found_label","The following {count} file(s)...").format (count =len (self .error_files )))
- self .select_all_button .setText (self ._tr ("error_files_select_all_button","Select All"))
- self .retry_button .setText (self ._tr ("error_files_retry_selected_button","Retry Selected"))
- self .export_button .setText (self ._tr ("error_files_export_urls_button","Export URLs to .txt"))
- self .ok_button .setText (self ._tr ("ok_button","OK"))
-
- def _select_all_items (self ):
- for i in range (self .files_list_widget .count ()):
- self .files_list_widget .item (i ).setCheckState (Qt .Checked )
-
- def _handle_retry_selected (self ):
- selected_files_for_retry =[self .files_list_widget .item (i ).data (Qt .UserRole )for i in range (self .files_list_widget .count ())if self .files_list_widget .item (i ).checkState ()==Qt .Checked ]
- if selected_files_for_retry :
- self .retry_selected_signal .emit (selected_files_for_retry )
- self .accept ()
- else :
- QMessageBox .information (self ,self ._tr ("fav_artists_no_selection_title","No Selection"),self ._tr ("error_files_no_selection_retry_message","Please select at least one file to retry."))
-
- def _handle_export_errors_to_txt (self ):
- if not self .error_files :
- QMessageBox .information (self ,self ._tr ("error_files_no_errors_export_title","No Errors"),self ._tr ("error_files_no_errors_export_message","There are no error file URLs to export."))
- return
-
-
- options_dialog =ExportOptionsDialog (parent_app =self .parent_app ,parent =self )
- if not options_dialog .exec_ ()==QDialog .Accepted :
-
- return
-
- export_option =options_dialog .get_selected_option ()
-
- lines_to_export =[]
- for error_item in self .error_files :
- file_info =error_item .get ('file_info',{})
- url =file_info .get ('url')
-
- if url :
- if export_option ==ExportOptionsDialog .EXPORT_MODE_WITH_DETAILS :
- original_filename =file_info .get ('name','Unknown Filename')
- post_title =error_item .get ('post_title','Unknown Post')
- post_id =error_item .get ('original_post_id_for_log','N/A')
- details_string =f" [Post: '{post_title }' (ID: {post_id }), File: '{original_filename }']"
- lines_to_export .append (f"{url }{details_string }")
- else :
- lines_to_export .append (url )
-
- if not lines_to_export :
- QMessageBox .information (self ,self ._tr ("error_files_no_urls_found_export_title","No URLs Found"),self ._tr ("error_files_no_urls_found_export_message","Could not extract any URLs..."))
- return
-
- default_filename ="error_file_links.txt"
- filepath ,_ =QFileDialog .getSaveFileName (
- self ,self ._tr ("error_files_save_dialog_title","Save Error File URLs"),default_filename ,"Text Files (*.txt);;All Files (*)"
- )
-
- if filepath :
- try :
- with open (filepath ,'w',encoding ='utf-8')as f :
- for line in lines_to_export :
- f .write (f"{line }\n")
- QMessageBox .information (self ,self ._tr ("error_files_export_success_title","Export Successful"),self ._tr ("error_files_export_success_message","Successfully exported...").format (count =len (lines_to_export ),filepath =filepath ))
- except Exception as e :
- QMessageBox .critical (self ,self ._tr ("error_files_export_error_title","Export Error"),self ._tr ("error_files_export_error_message","Could not export...").format (error =str (e )))
- else :
-
- pass
-class FutureSettingsDialog (QDialog ):
- """A simple dialog as a placeholder for future settings."""
- def __init__ (self ,parent_app_ref ,parent =None ):
- super ().__init__ (parent )
- self .parent_app =parent_app_ref
- self .setModal (True )
-
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
-
- base_min_w ,base_min_h =380 ,250
- 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 )
-
- layout =QVBoxLayout (self )
-
-
- self .appearance_group_box =QGroupBox ()
- appearance_layout =QVBoxLayout (self .appearance_group_box )
-
- self .theme_toggle_button =QPushButton ()
- self ._update_theme_toggle_button_text ()
- self .theme_toggle_button .clicked .connect (self ._toggle_theme )
- appearance_layout .addWidget (self .theme_toggle_button )
- layout .addWidget (self .appearance_group_box )
-
-
- self .language_group_box =QGroupBox ()
- language_group_layout =QVBoxLayout (self .language_group_box )
-
- self .language_selection_layout =QHBoxLayout ()
- self .language_label =QLabel ()
- self .language_selection_layout .addWidget (self .language_label )
-
- self .language_combo_box =QComboBox ()
- self .language_combo_box .currentIndexChanged .connect (self ._language_selection_changed )
- self .language_selection_layout .addWidget (self .language_combo_box ,1 )
- language_group_layout .addLayout (self .language_selection_layout )
- layout .addWidget (self .language_group_box )
-
-
- self .save_path_button =QPushButton ()
- self .save_path_button .clicked .connect (self ._save_download_path )
- layout .addWidget (self .save_path_button )
-
-
- layout .addStretch (1 )
-
- self .ok_button =QPushButton ()
- self .ok_button .clicked .connect (self .accept )
- layout .addWidget (self .ok_button ,0 ,Qt .AlignRight |Qt .AlignBottom )
-
- self ._retranslate_ui ()
- self ._apply_dialog_theme ()
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation ):
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("settings_dialog_title","Settings"))
- self .appearance_group_box .setTitle (self ._tr ("appearance_group_title","Appearance"))
- self .language_group_box .setTitle (self ._tr ("language_group_title","Language Settings"))
- self .language_label .setText (self ._tr ("language_label","Language:"))
- self ._update_theme_toggle_button_text ()
- self ._populate_language_combo_box ()
- if hasattr (self ,'save_path_button'):
- self .save_path_button .setText (self ._tr ("settings_save_path_button","Save Current Download Path"))
- self .save_path_button .setToolTip (self ._tr ("settings_save_path_tooltip","Save the current 'Download Location' from the main window for future sessions."))
- self .ok_button .setText (self ._tr ("ok_button","OK"))
- def _update_theme_toggle_button_text (self ):
- if self .parent_app .current_theme =="dark":
- self .theme_toggle_button .setText (self ._tr ("theme_toggle_light","Switch to Light Mode"))
- self .theme_toggle_button .setToolTip (self ._tr ("theme_tooltip_light","Change the application appearance to light."))
- else :
- self .theme_toggle_button .setText (self ._tr ("theme_toggle_dark","Switch to Dark Mode"))
- self .theme_toggle_button .setToolTip (self ._tr ("theme_tooltip_dark","Change the application appearance to dark."))
-
- def _toggle_theme (self ):
- if self .parent_app .current_theme =="dark":
- self .parent_app .apply_theme ("light")
- else :
- self .parent_app .apply_theme ("dark")
-
- self ._retranslate_ui ()
- self ._apply_dialog_theme ()
-
- def _apply_dialog_theme (self ):
- if self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
- else :
- self .setStyleSheet ("")
-
- def _populate_language_combo_box (self ):
- self .language_combo_box .blockSignals (True )
- self .language_combo_box .clear ()
- languages =[
- ("en","English"),
- ("ja","日本語 (Japanese)"),
- ("fr","Français (French)"),
- ("de","Deutsch (German)"),
- ("es","Español (Spanish)"),
- ("pt","Português (Portuguese)"),
- ("ru","Русский (Russian)"),
- ("zh_CN","简体中文 (Simplified Chinese)"),
- ("zh_TW","繁體中文 (Traditional Chinese)"),
- ("ko","한국어 (Korean)")
- ]
- for lang_code ,lang_name in languages :
- self .language_combo_box .addItem (lang_name ,lang_code )
- if self .parent_app .current_selected_language ==lang_code :
- self .language_combo_box .setCurrentIndex (self .language_combo_box .count ()-1 )
- self .language_combo_box .blockSignals (False )
-
- def _language_selection_changed (self ,index ):
- selected_lang_code =self .language_combo_box .itemData (index )
- if selected_lang_code and selected_lang_code !=self .parent_app .current_selected_language :
- self .parent_app .current_selected_language =selected_lang_code
- self .parent_app .settings .setValue (LANGUAGE_KEY ,self .parent_app .current_selected_language )
- self .parent_app .settings .sync ()
- self ._retranslate_ui ()
-
-
-
-
-
- msg_box =QMessageBox (self )
- msg_box .setIcon (QMessageBox .Information )
- msg_box .setWindowTitle (self ._tr ("language_change_title","Language Changed"))
- msg_box .setText (self ._tr ("language_change_message","The language has been changed. A restart is required for all changes to take full effect."))
- msg_box .setInformativeText (self ._tr ("language_change_informative","Would you like to restart the application now?"))
-
- restart_button =msg_box .addButton (self ._tr ("restart_now_button","Restart Now"),QMessageBox .ApplyRole )
- ok_button =msg_box .addButton (self ._tr ("ok_button","OK"),QMessageBox .AcceptRole )
-
- msg_box .setDefaultButton (ok_button )
- msg_box .exec_ ()
-
- if msg_box .clickedButton ()==restart_button :
- self .parent_app ._request_restart_application ()
-
- def _save_download_path (self ):
- if self .parent_app and hasattr (self .parent_app ,'dir_input')and self .parent_app .dir_input :
- current_path =self .parent_app .dir_input .text ().strip ()
- if current_path :
- if os .path .isdir (current_path ):
- self .parent_app .settings .setValue (DOWNLOAD_LOCATION_KEY ,current_path )
- self .parent_app .settings .sync ()
- QMessageBox .information (self ,
- self ._tr ("settings_save_path_success_title","Path Saved"),
- self ._tr ("settings_save_path_success_message",f"Download location '{current_path }' saved successfully."))
- if hasattr (self .parent_app ,'log_signal'):
- self .parent_app .log_signal .emit (f"💾 Download location '{current_path }' saved.")
- else :
- QMessageBox .warning (self ,
- self ._tr ("settings_save_path_invalid_title","Invalid Path"),
- self ._tr ("settings_save_path_invalid_message",f"The path '{current_path }' is not a valid directory. Please select a valid directory first."))
- else :
- QMessageBox .warning (self ,
- self ._tr ("settings_save_path_empty_title","Empty Path"),
- self ._tr ("settings_save_path_empty_message","Download location cannot be empty. Please select a path first."))
- else :
- QMessageBox .critical (self ,"Error","Could not access download path input from main application.")
-
-class EmptyPopupDialog (QDialog ):
- """A simple empty popup dialog."""
- SCOPE_CHARACTERS ="Characters"
- INITIAL_LOAD_LIMIT =200
- SCOPE_CREATORS ="Creators"
-
-
- def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
- super ().__init__ (parent )
- self .setMinimumSize (400 ,300 )
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
- self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
-
- self .parent_app =parent_app_ref
- self .current_scope_mode =self .SCOPE_CHARACTERS
- self .app_base_dir =app_base_dir
-
- app_icon =get_app_icon_object ()
- if app_icon and not app_icon .isNull ():
- self .setWindowIcon (app_icon )
- self .selected_creators_for_queue =[]
- self .globally_selected_creators ={}
- self .fetched_posts_data ={}
- self .post_fetch_thread =None
- self .TITLE_COLUMN_WIDTH_FOR_POSTS =70
- self .globally_selected_post_ids =set ()
- self ._is_scrolling_titles =False
- self ._is_scrolling_dates =False
-
-
- dialog_layout =QHBoxLayout (self )
- self .setLayout (dialog_layout )
-
-
-
- self .left_pane_widget =QWidget ()
- left_pane_layout =QVBoxLayout (self .left_pane_widget )
-
-
- search_fetch_layout =QHBoxLayout ()
- self .search_input =QLineEdit ()
- self .search_input .textChanged .connect (self ._filter_list )
- search_fetch_layout .addWidget (self .search_input ,1 )
- self .fetch_posts_button =QPushButton ()
- self .fetch_posts_button .setEnabled (False )
- self .fetch_posts_button .clicked .connect (self ._handle_fetch_posts_click )
- search_fetch_layout .addWidget (self .fetch_posts_button )
- left_pane_layout .addLayout (search_fetch_layout )
-
- self .progress_bar =QProgressBar ()
- self .progress_bar .setRange (0 ,0 )
- self .progress_bar .setTextVisible (False )
- self .progress_bar .setVisible (False )
- left_pane_layout .addWidget (self .progress_bar )
-
- self .list_widget =QListWidget ()
- self .list_widget .itemChanged .connect (self ._handle_item_check_changed )
- left_pane_layout .addWidget (self .list_widget )
-
-
- left_bottom_buttons_layout =QHBoxLayout ()
- self .add_selected_button =QPushButton ()
- self .add_selected_button .setToolTip (
- "Add Selected Creators to URL Input\n\n"
- "Adds the names of all checked creators to the main URL input field,\n"
- "comma-separated, and closes this dialog."
- )
- self .add_selected_button .clicked .connect (self ._handle_add_selected )
- self .add_selected_button .setDefault (True )
- left_bottom_buttons_layout .addWidget (self .add_selected_button )
- self .scope_button =QPushButton ()
- 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 .right_pane_widget =QWidget ()
- right_pane_layout =QVBoxLayout (self .right_pane_widget )
-
- self .posts_area_title_label =QLabel ("Fetched Posts")
- self .posts_area_title_label .setAlignment (Qt .AlignCenter )
- right_pane_layout .addWidget (self .posts_area_title_label )
-
- self .posts_search_input =QLineEdit ()
- self .posts_search_input .setVisible (False )
-
- self .posts_search_input .textChanged .connect (self ._filter_fetched_posts_list )
- right_pane_layout .addWidget (self .posts_search_input )
-
-
- posts_headers_layout =QHBoxLayout ()
- self .posts_title_header_label =QLabel ()
- self .posts_title_header_label .setStyleSheet ("font-weight: bold; padding-left: 20px;")
- posts_headers_layout .addWidget (self .posts_title_header_label ,7 )
-
- self .posts_date_header_label =QLabel ()
- self .posts_date_header_label .setStyleSheet ("font-weight: bold;")
- posts_headers_layout .addWidget (self .posts_date_header_label ,3 )
- right_pane_layout .addLayout (posts_headers_layout )
-
-
-
- self .posts_content_splitter =QSplitter (Qt .Horizontal )
-
- self .posts_title_list_widget =QListWidget ()
- self .posts_title_list_widget .itemChanged .connect (self ._handle_post_item_check_changed )
- self .posts_title_list_widget .setAlternatingRowColors (True )
- self .posts_content_splitter .addWidget (self .posts_title_list_widget )
-
- self .posts_date_list_widget =QListWidget ()
- self .posts_date_list_widget .setSelectionMode (QAbstractItemView .NoSelection )
- self .posts_date_list_widget .setAlternatingRowColors (True )
- self .posts_date_list_widget .setHorizontalScrollBarPolicy (Qt .ScrollBarAlwaysOff )
- self .posts_content_splitter .addWidget (self .posts_date_list_widget )
-
- right_pane_layout .addWidget (self .posts_content_splitter ,1 )
-
- posts_buttons_top_layout =QHBoxLayout ()
- self .posts_select_all_button =QPushButton ()
- self .posts_select_all_button .clicked .connect (self ._handle_posts_select_all )
- posts_buttons_top_layout .addWidget (self .posts_select_all_button )
-
- self .posts_deselect_all_button =QPushButton ()
- self .posts_deselect_all_button .clicked .connect (self ._handle_posts_deselect_all )
- posts_buttons_top_layout .addWidget (self .posts_deselect_all_button )
- right_pane_layout .addLayout (posts_buttons_top_layout )
-
- posts_buttons_bottom_layout =QHBoxLayout ()
- self .posts_add_selected_button =QPushButton ()
- self .posts_add_selected_button .clicked .connect (self ._handle_posts_add_selected_to_queue )
- posts_buttons_bottom_layout .addWidget (self .posts_add_selected_button )
-
- self .posts_close_button =QPushButton ()
- self .posts_close_button .clicked .connect (self ._handle_posts_close_view )
- posts_buttons_bottom_layout .addWidget (self .posts_close_button )
- right_pane_layout .addLayout (posts_buttons_bottom_layout )
-
- self .right_pane_widget .hide ()
-
-
-
-
-
- self .main_splitter =QSplitter (Qt .Horizontal )
- self .main_splitter .addWidget (self .left_pane_widget )
- self .main_splitter .addWidget (self .right_pane_widget )
- self .main_splitter .setCollapsible (0 ,False )
- self .main_splitter .setCollapsible (1 ,True )
-
-
- self .posts_title_list_widget .verticalScrollBar ().valueChanged .connect (self ._sync_scroll_dates )
- self .posts_date_list_widget .verticalScrollBar ().valueChanged .connect (self ._sync_scroll_titles )
- dialog_layout .addWidget (self .main_splitter )
-
- self .original_size =self .sizeHint ()
- self .main_splitter .setSizes ([int (self .width ()*scale_factor ),0 ])
-
- self ._retranslate_ui ()
-
- if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
-
-
- self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor ))
-
- QTimer .singleShot (0 ,self ._perform_initial_load )
-
- def _center_on_screen (self ):
- """Centers the dialog on the parent's screen or the primary screen."""
- if self .parent_app :
- parent_rect =self .parent_app .frameGeometry ()
- self .move (parent_rect .center ()-self .rect ().center ())
- else :
- try :
- screen_geo =QApplication .primaryScreen ().availableGeometry ()
- self .move (screen_geo .center ()-self .rect ().center ())
- except AttributeError :
- pass
-
- def _handle_fetch_posts_click (self ):
- selected_creators =list (self .globally_selected_creators .values ())
- if not selected_creators :
- QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
- "Please select at least one creator to fetch posts for.")
- return
-
- if self .parent_app :
- parent_geometry =self .parent_app .geometry ()
- new_width =int (parent_geometry .width ()*0.75 )
- new_height =int (parent_geometry .height ()*0.80 )
- self .resize (new_width ,new_height )
- self ._center_on_screen ()
-
- self .right_pane_widget .show ()
- QTimer .singleShot (10 ,lambda :self .main_splitter .setSizes ([int (self .width ()*0.3 ),int (self .width ()*0.7 )]))
-
- QTimer .singleShot (20 ,lambda :self .posts_content_splitter .setSizes ([int (self .posts_content_splitter .width ()*0.7 ),int (self .posts_content_splitter .width ()*0.3 )]))
- self .add_selected_button .setEnabled (False )
- self .globally_selected_post_ids .clear ()
- self .posts_search_input .setVisible (True )
- self .setWindowTitle (self ._tr ("creator_popup_title_fetching","Creator Posts"))
-
- self .fetch_posts_button .setEnabled (False )
- self .posts_title_list_widget .clear ()
- self .posts_date_list_widget .clear ()
- self .fetched_posts_data .clear ()
- self .posts_area_title_label .setText (self ._tr ("fav_posts_loading_status","Loading favorite posts..."))
- self .posts_title_list_widget .itemChanged .connect (self ._handle_post_item_check_changed )
- self .progress_bar .setVisible (True )
-
- if self .post_fetch_thread and self .post_fetch_thread .isRunning ():
- self .post_fetch_thread .cancel ()
- self .post_fetch_thread .wait ()
- self .post_fetch_thread =PostsFetcherThread (selected_creators ,self )
- self .post_fetch_thread .status_update .connect (self ._handle_fetch_status_update )
- self .post_fetch_thread .posts_fetched_signal .connect (self ._handle_posts_fetched )
- self .post_fetch_thread .fetch_error_signal .connect (self ._handle_fetch_error )
- self .post_fetch_thread .finished_signal .connect (self ._handle_fetch_finished )
- self .post_fetch_thread .start ()
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
- self .search_input .setPlaceholderText (self ._tr ("creator_popup_search_placeholder","Search by name, service, or paste creator URL..."))
- 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 .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))
-
- self .posts_title_header_label .setText (self ._tr ("column_header_post_title","Post Title"))
- self .posts_date_header_label .setText (self ._tr ("column_header_date_uploaded","Date Uploaded"))
-
- self .posts_area_title_label .setText (self ._tr ("creator_popup_posts_area_title","Fetched Posts"))
- self .posts_select_all_button .setText (self ._tr ("select_all_button_text","Select All"))
- self .posts_deselect_all_button .setText (self ._tr ("deselect_all_button_text","Deselect All"))
- self .posts_add_selected_button .setText (self ._tr ("creator_popup_add_posts_to_queue_button","Add Selected Posts to Queue"))
- self .posts_close_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
-
- def _sync_scroll_dates (self ,value ):
- if not self ._is_scrolling_titles :
- self ._is_scrolling_dates =True
- self .posts_date_list_widget .verticalScrollBar ().setValue (value )
- self ._is_scrolling_dates =False
-
- def _sync_scroll_titles (self ,value ):
- if not self ._is_scrolling_dates :
- self ._is_scrolling_titles =True
- self .posts_title_list_widget .verticalScrollBar ().setValue (value )
- self ._is_scrolling_titles =False
-
- def _perform_initial_load (self ):
- """Called by QTimer to load data after dialog is shown."""
- self ._load_creators_from_json ()
-
-
-
- def _load_creators_from_json (self ):
- """Loads creators from creators.json and populates the list widget."""
- self .list_widget .clear ()
-
- self .progress_bar .setVisible (True )
- QCoreApplication .processEvents ()
- if not self .isVisible ():return
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
- base_path_for_creators =sys ._MEIPASS
- else :
- base_path_for_creators =self .app_base_dir
- creators_file_path =os .path .join (base_path_for_creators ,"creators.json")
-
- if not os .path .exists (creators_file_path ):
- self .list_widget .addItem (f"Error: creators.json not found at {creators_file_path }")
- self .all_creators_data =[]
- self .progress_bar .setVisible (False )
- QCoreApplication .processEvents ()
- return
-
- try :
- with open (creators_file_path ,'r',encoding ='utf-8')as f :
- data =json .load (f )
- QCoreApplication .processEvents ()
- if not self .isVisible ():return
-
- raw_data_list =[]
- if isinstance (data ,list )and len (data )>0 and isinstance (data [0 ],list ):
- raw_data_list =data [0 ]
- elif isinstance (data ,list )and all (isinstance (item ,dict )for item in data ):
- raw_data_list =data
- else :
- self .list_widget .addItem ("Error: Invalid format in creators.json.")
- self .all_creators_data =[]
- self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
-
-
- unique_creators_map ={}
- duplicates_skipped_count =0
- for creator_entry in raw_data_list :
- service =creator_entry .get ('service')
- creator_id =creator_entry .get ('id')
- name_for_log =creator_entry .get ('name','Unknown')
-
- if service and creator_id :
- key =(str (service ).lower ().strip (),str (creator_id ).strip ())
- if key not in unique_creators_map :
- unique_creators_map [key ]=creator_entry
- else :
- duplicates_skipped_count +=1
- else :
- print (f"Warning: Creator entry in creators.json missing service or ID: {name_for_log }")
-
- self .all_creators_data =list (unique_creators_map .values ())
- if duplicates_skipped_count >0 :
- print (f"INFO (Creator Popup): Skipped {duplicates_skipped_count } duplicate creator entries from creators.json based on (service, id).")
-
- self .all_creators_data .sort (key =lambda c :(-c .get ('favorited',0 ),c .get ('name','').lower ()))
- QCoreApplication .processEvents ()
- if not self .isVisible ():return
-
- except json .JSONDecodeError :
- self .list_widget .addItem ("Error: Could not parse creators.json.")
- self .all_creators_data =[]
- self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
- except Exception as e :
- self .list_widget .addItem (f"Error loading creators: {e }")
- self .all_creators_data =[]
- self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
-
- self ._filter_list ()
-
- def _populate_list_widget (self ,creators_to_display ):
- """Clears and populates the list widget with the given creator data."""
- self .list_widget .blockSignals (True )
- self .list_widget .clear ()
-
- if not creators_to_display :
- self .list_widget .blockSignals (False )
-
-
-
-
-
-
- return
-
- CHUNK_SIZE_POPULATE =100
- for i in range (0 ,len (creators_to_display ),CHUNK_SIZE_POPULATE ):
- if not self .isVisible ():
- self .list_widget .blockSignals (False )
- return
-
- chunk =creators_to_display [i :i +CHUNK_SIZE_POPULATE ]
- for creator in chunk :
- creator_name_raw =creator .get ('name')
- display_creator_name =creator_name_raw .strip ()if isinstance (creator_name_raw ,str )and creator_name_raw .strip ()else "Unknown Creator"
- service_display_name =creator .get ('service','N/A').capitalize ()
- display_text =f"{display_creator_name } ({service_display_name })"
- item =QListWidgetItem (display_text )
- item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
- item .setData (Qt .UserRole ,creator )
- service =creator .get ('service')
- creator_id =creator .get ('id')
- if service is not None and creator_id is not None :
- unique_key =(str (service ),str (creator_id ))
- if unique_key in self .globally_selected_creators :
- item .setCheckState (Qt .Checked )
- else :
- item .setCheckState (Qt .Unchecked )
- else :
- item .setCheckState (Qt .Unchecked )
- self .list_widget .addItem (item )
-
- QCoreApplication .processEvents ()
-
- self .list_widget .blockSignals (False )
-
- def _filter_list (self ):
- """Filters the list widget based on the search input."""
- raw_search_input =self .search_input .text ()
-
-
-
- check_search_text_for_empty =raw_search_input .lower ().strip ()
-
- QCoreApplication .processEvents ()
- if not self .isVisible ():return
-
- if not check_search_text_for_empty :
- self .progress_bar .setVisible (False )
- creators_to_show =self .all_creators_data [:self .INITIAL_LOAD_LIMIT ]
- self ._populate_list_widget (creators_to_show )
- self .search_input .setToolTip ("Search by name, service, or paste creator URL...")
- QCoreApplication .processEvents ()
- else :
- self .progress_bar .setVisible (True )
- QCoreApplication .processEvents ()
- if not self .isVisible ():return
-
- norm_search_casefolded =unicodedata .normalize ('NFKC',raw_search_input ).casefold ().strip ()
-
- scored_matches =[]
-
- parsed_service_from_url ,parsed_user_id_from_url ,_ =extract_post_info (raw_search_input )
-
- if parsed_service_from_url and parsed_user_id_from_url :
-
- self .search_input .setToolTip (f"Searching for URL: {raw_search_input [:50 ]}...")
- for creator_data in self .all_creators_data :
- creator_service_lower =creator_data .get ('service','').lower ()
-
- creator_id_str_lower =str (creator_data .get ('id','')).lower ()
-
- if creator_service_lower ==parsed_service_from_url .lower ()and creator_id_str_lower ==parsed_user_id_from_url .lower ():
- scored_matches .append ((5 ,creator_data ))
-
- break
-
- else :
-
-
- self .search_input .setToolTip ("Searching by name or service...")
- norm_search_casefolded =unicodedata .normalize ('NFKC',raw_search_input ).casefold ().strip ()
-
- CHUNK_SIZE_FILTER =500
- for i in range (0 ,len (self .all_creators_data ),CHUNK_SIZE_FILTER ):
- if not self .isVisible ():break
- chunk =self .all_creators_data [i :i +CHUNK_SIZE_FILTER ]
- for creator_data in chunk :
- creator_name_raw =creator_data .get ('name','')
- creator_service_raw =creator_data .get ('service','')
-
-
- norm_creator_name_casefolded =unicodedata .normalize ('NFKC',creator_name_raw ).casefold ()
- norm_service_casefolded =unicodedata .normalize ('NFKC',creator_service_raw ).casefold ()
-
- current_score =0
-
- if norm_search_casefolded ==norm_creator_name_casefolded :
- current_score =4
-
- elif norm_creator_name_casefolded .startswith (norm_search_casefolded ):
- current_score =3
-
- elif norm_search_casefolded in norm_creator_name_casefolded :
- current_score =2
-
- elif norm_search_casefolded in norm_service_casefolded :
- current_score =1
-
- if current_score >0 :
- scored_matches .append ((current_score ,creator_data ))
-
- QCoreApplication .processEvents ()
-
-
-
- scored_matches .sort (key =lambda x :(-x [0 ],unicodedata .normalize ('NFKC',x [1 ].get ('name','')).casefold ()))
-
-
- final_creators_to_display =[creator_data for score ,creator_data in scored_matches [:20 ]]
-
- self ._populate_list_widget (final_creators_to_display )
- self .progress_bar .setVisible (False )
-
-
- if parsed_service_from_url and parsed_user_id_from_url :
- if final_creators_to_display :
- self .search_input .setToolTip (f"Found creator by URL: {final_creators_to_display [0 ].get ('name')}")
- else :
- self .search_input .setToolTip (f"URL parsed, but no matching creator found in your creators.json.")
- else :
- if final_creators_to_display :
- self .search_input .setToolTip (f"Showing top {len (final_creators_to_display )} match(es) for '{raw_search_input [:30 ]}...'")
- else :
- self .search_input .setToolTip (f"No matches found for '{raw_search_input [:30 ]}...'")
-
- def _toggle_scope_mode (self ):
- """Toggles the scope mode and updates the button text."""
- if self .current_scope_mode ==self .SCOPE_CHARACTERS :
- self .current_scope_mode =self .SCOPE_CREATORS
- else :
- self .current_scope_mode =self .SCOPE_CHARACTERS
- self ._update_scope_button_text_and_tooltip ()
-
- def _update_scope_button_text_and_tooltip (self ):
- if self .current_scope_mode ==self .SCOPE_CHARACTERS :
- self .scope_button .setText (self ._tr ("creator_popup_scope_characters_button","Scope: Characters"))
- else :
- self .scope_button .setText (self ._tr ("creator_popup_scope_creators_button","Scope: Creators"))
-
- self .scope_button .setToolTip (
- f"Current Download Scope: {self .current_scope_mode }\n\n"
- f"Click to toggle between '{self .SCOPE_CHARACTERS }' and '{self .SCOPE_CREATORS }' scopes.\n"
- f"'{self .SCOPE_CHARACTERS }': Downloads into character-named folders directly in the main Download Location (artists mixed).\n"
- f"'{self .SCOPE_CREATORS }': Downloads into artist-named subfolders within the main Download Location, then character folders inside those.")
-
- def _handle_fetch_status_update (self ,message ):
- if self .parent_app :
- self .parent_app .log_signal .emit (f"[CreatorPopup Fetch] {message }")
- self .posts_area_title_label .setText (message )
-
- def _handle_posts_fetched (self ,creator_info ,posts_list ):
- creator_key =(creator_info .get ('service'),str (creator_info .get ('id')))
-
- self .fetched_posts_data [creator_key ]=(creator_info ,posts_list )
- self ._filter_fetched_posts_list ()
-
- def _filter_fetched_posts_list (self ):
- search_text =self .posts_search_input .text ().lower ().strip ()
-
- data_for_rebuild ={}
-
- if not self .fetched_posts_data :
- self .posts_area_title_label .setText (self ._tr ("no_posts_fetched_yet_status","No posts fetched yet."))
- elif not search_text :
- data_for_rebuild =self .fetched_posts_data
-
- total_posts_in_view =sum (len (posts_tuple [1 ])for posts_tuple in data_for_rebuild .values ())
- if total_posts_in_view >0 :
- self .posts_area_title_label .setText (self ._tr ("fetched_posts_count_label","Fetched {count} post(s). Select to add to queue.").format (count =total_posts_in_view ))
- else :
- self .posts_area_title_label .setText (self ._tr ("no_posts_found_for_selection","No posts found for selected creator(s)."))
- else :
- for creator_key ,(creator_data_tuple_part ,posts_list_tuple_part )in self .fetched_posts_data .items ():
- matching_posts_for_creator =[
- post for post in posts_list_tuple_part
- if search_text in post .get ('title','').lower ()
- ]
- if matching_posts_for_creator :
-
- data_for_rebuild [creator_key ]=(creator_data_tuple_part ,matching_posts_for_creator )
-
-
- total_matching_posts =sum (len (posts_tuple [1 ])for posts_tuple in data_for_rebuild .values ())
- if total_matching_posts >0 :
- self .posts_area_title_label .setText (self ._tr ("fetched_posts_count_label_filtered","Displaying {count} post(s) matching filter.").format (count =total_matching_posts ))
- else :
- self .posts_area_title_label .setText (self ._tr ("no_posts_match_search_filter","No posts match your search filter."))
-
- self ._rebuild_posts_list_widget (filtered_data_map =data_for_rebuild )
-
- def _rebuild_posts_list_widget (self ,filtered_data_map ):
- self .posts_title_list_widget .blockSignals (True )
- self .posts_date_list_widget .blockSignals (True )
- self .posts_title_list_widget .clear ()
- self .posts_date_list_widget .clear ()
- data_to_display =filtered_data_map
-
- if not data_to_display :
- self .posts_title_list_widget .blockSignals (False )
- self .posts_date_list_widget .blockSignals (False )
- return
-
-
- sorted_creator_keys =sorted (
- data_to_display .keys (),
- key =lambda k :data_to_display [k ][0 ].get ('name','').lower ()
- )
-
- total_posts_shown =0
- for creator_key in sorted_creator_keys :
-
- creator_info_original ,posts_for_this_creator =data_to_display .get (creator_key ,(None ,[]))
-
- if not creator_info_original or not posts_for_this_creator :
- continue
-
- creator_header_item =QListWidgetItem (f"--- {self ._tr ('posts_for_creator_header','Posts for')} {creator_info_original ['name']} ({creator_info_original ['service']}) ---")
- font =creator_header_item .font ()
- font .setBold (True )
- creator_header_item .setFont (font )
- creator_header_item .setFlags (Qt .NoItemFlags )
- self .posts_title_list_widget .addItem (creator_header_item )
- self .posts_date_list_widget .addItem (QListWidgetItem (""))
-
- for post in posts_for_this_creator :
- post_title =post .get ('title',self ._tr ('untitled_post_placeholder','Untitled Post'))
-
-
- date_prefix_str ="[No Date]"
- published_date_str =post .get ('published')
- added_date_str =post .get ('added')
-
- date_to_use_str =None
- if published_date_str :
- date_to_use_str =published_date_str
- elif added_date_str :
- date_to_use_str =added_date_str
-
- if date_to_use_str :
- try :
-
- formatted_date =date_to_use_str .split ('T')[0 ]
- date_prefix_str =f"[{formatted_date }]"
- except Exception :
- pass
-
-
- date_display_str ="[No Date]"
- published_date_str =post .get ('published')
- added_date_str =post .get ('added')
-
- date_to_use_str =None
- if published_date_str :
- date_to_use_str =published_date_str
- elif added_date_str :
- date_to_use_str =added_date_str
-
- if date_to_use_str :
- try :
-
- formatted_date =date_to_use_str .split ('T')[0 ]
- date_display_str =f"[{formatted_date }]"
- except Exception :
- pass
-
-
- title_item_text =f" {post_title }"
- item =QListWidgetItem (title_item_text )
- item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
- item .setCheckState (Qt .Unchecked )
- item_data ={
- 'title':post_title ,
- 'id':post .get ('id'),
- 'service':creator_info_original ['service'],
- 'user_id':creator_info_original ['id'],
- 'creator_name':creator_info_original ['name'],
- 'full_post_data':post ,
- 'date_display_str':date_display_str ,
- 'published_date_for_sort':date_to_use_str
- }
- item .setData (Qt .UserRole ,item_data )
- post_unique_key =(
- item_data ['service'],
- str (item_data ['user_id']),
- str (item_data ['id'])
- )
- if post_unique_key in self .globally_selected_post_ids :
- item .setCheckState (Qt .Checked )
- else :
- item .setCheckState (Qt .Unchecked )
-
- self .posts_title_list_widget .addItem (item )
- total_posts_shown +=1
-
- date_item =QListWidgetItem (f" {date_display_str }")
- date_item .setFlags (Qt .NoItemFlags )
- self .posts_date_list_widget .addItem (date_item )
-
- self .posts_title_list_widget .blockSignals (False )
- self .posts_date_list_widget .blockSignals (False )
-
- def _handle_fetch_error (self ,creator_info ,error_message ):
- creator_name =creator_info .get ('name','Unknown Creator')
- if self .parent_app :
- self .parent_app .log_signal .emit (f"[CreatorPopup Fetch ERROR] For {creator_name }: {error_message }")
-
- self .posts_area_title_label .setText (self ._tr ("fetch_error_for_creator_label","Error fetching for {creator_name}").format (creator_name =creator_name ))
-
-
- def _handle_fetch_finished (self ):
- self .fetch_posts_button .setEnabled (True )
- self .progress_bar .setVisible (False )
-
- if not self .fetched_posts_data :
- if self .post_fetch_thread and self .post_fetch_thread .cancellation_flag .is_set ():
- self .posts_area_title_label .setText (self ._tr ("post_fetch_cancelled_status_done","Post fetching cancelled."))
- else :
- self .posts_area_title_label .setText (self ._tr ("failed_to_fetch_or_no_posts_label","Failed to fetch posts or no posts found."))
- self .posts_search_input .setVisible (False )
- elif not self .posts_title_list_widget .count ()and not self .posts_search_input .text ().strip ():
- self .posts_area_title_label .setText (self ._tr ("no_posts_found_for_selection","No posts found for selected creator(s)."))
- self .posts_search_input .setVisible (True )
- else :
- QTimer .singleShot (10 ,lambda :self .posts_content_splitter .setSizes ([int (self .posts_content_splitter .width ()*0.7 ),int (self .posts_content_splitter .width ()*0.3 )]))
- self .posts_search_input .setVisible (True )
-
- def _handle_posts_select_all (self ):
- self .posts_title_list_widget .blockSignals (True )
- for i in range (self .posts_title_list_widget .count ()):
- item =self .posts_title_list_widget .item (i )
- if item .flags ()&Qt .ItemIsUserCheckable :
- item .setCheckState (Qt .Checked )
-
-
- item_data =item .data (Qt .UserRole )
- if item_data :
- post_unique_key =(
- item_data ['service'],
- str (item_data ['user_id']),
- str (item_data ['id'])
- )
- self .globally_selected_post_ids .add (post_unique_key )
- self .posts_title_list_widget .blockSignals (False )
-
- def _handle_posts_deselect_all (self ):
- self .posts_title_list_widget .blockSignals (True )
- for i in range (self .posts_title_list_widget .count ()):
- item =self .posts_title_list_widget .item (i )
- if item .flags ()&Qt .ItemIsUserCheckable :
- item .setCheckState (Qt .Unchecked )
- self .globally_selected_post_ids .clear ()
- self .posts_title_list_widget .blockSignals (False )
-
- def _handle_post_item_check_changed (self ,item ):
- if not item or not item .data (Qt .UserRole ):
- return
-
- item_data =item .data (Qt .UserRole )
- post_unique_key =(
- item_data ['service'],
- str (item_data ['user_id']),
- str (item_data ['id'])
- )
-
- if item .checkState ()==Qt .Checked :
- self .globally_selected_post_ids .add (post_unique_key )
- else :
- self .globally_selected_post_ids .discard (post_unique_key )
-
- def _handle_posts_add_selected_to_queue (self ):
- selected_posts_for_queue =[]
- if not self .globally_selected_post_ids :
- QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
- self ._tr ("select_posts_to_queue_message","Please select at least one post to add to the queue."))
- return
-
- for post_key in self .globally_selected_post_ids :
- service ,user_id_str ,post_id_str =post_key
- post_data_found =None
- creator_key_for_fetched_data =(service ,user_id_str )
-
-
- if creator_key_for_fetched_data in self .fetched_posts_data :
- _unused_creator_info ,posts_in_list_for_creator =self .fetched_posts_data [creator_key_for_fetched_data ]
- for post_in_list in posts_in_list_for_creator :
- if str (post_in_list .get ('id'))==post_id_str :
- post_data_found =post_in_list
- break
-
- if post_data_found :
-
- creator_info_original ,_unused_posts =self .fetched_posts_data .get (creator_key_for_fetched_data ,({},[]))
- creator_name =creator_info_original .get ('name','Unknown Creator')if creator_info_original else 'Unknown Creator'
-
- domain =self ._get_domain_for_service (service )
- post_url =f"https://{domain }/{service }/user/{user_id_str }/post/{post_id_str }"
- queue_item ={
- 'type':'single_post_from_popup',
- 'url':post_url ,
- 'name':post_data_found .get ('title',self ._tr ('untitled_post_placeholder','Untitled Post')),
- 'name_for_folder':creator_name ,
- 'service':service ,
- 'user_id':user_id_str ,
- 'post_id':post_id_str
- }
- selected_posts_for_queue .append (queue_item )
- else :
-
-
-
-
- if self .parent_app and hasattr (self .parent_app ,'log_signal'):
- self .parent_app .log_signal .emit (f"⚠️ Could not find full post data for selected key: {post_key } when adding to queue.")
-
- else :
- domain =self ._get_domain_for_service (service )
- post_url =f"https://{domain }/{service }/user/{user_id_str }/post/{post_id_str }"
- queue_item ={
- 'type':'single_post_from_popup',
- 'url':post_url ,
- 'name':f"post id {post_id_str }",
- 'name_for_folder':user_id_str ,
- 'service':service ,
- 'user_id':user_id_str ,
- 'post_id':post_id_str
- }
- selected_posts_for_queue .append (queue_item )
-
- if selected_posts_for_queue :
- if self .parent_app and hasattr (self .parent_app ,'favorite_download_queue'):
- for qi in selected_posts_for_queue :
- self .parent_app .favorite_download_queue .append (qi )
-
- num_just_added_posts =len (selected_posts_for_queue )
- total_in_queue =len (self .parent_app .favorite_download_queue )
-
- self .parent_app .log_signal .emit (f"ℹ️ Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.")
-
- if self .parent_app .link_input :
- self .parent_app .link_input .blockSignals (True )
- self .parent_app .link_input .setText (
- self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts )
- )
- self .parent_app .link_input .blockSignals (False )
- self .parent_app .link_input .setPlaceholderText (
- self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue )
- )
- self .accept ()
- else :
- QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
- self ._tr ("select_posts_to_queue_message","Please select at least one post to add to the queue."))
-
- def _handle_posts_close_view (self ):
- self .right_pane_widget .hide ()
- self .main_splitter .setSizes ([self .width (),0 ])
- self .posts_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
- if hasattr (self ,'_handle_post_item_check_changed'):
- self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
- self .posts_search_input .setVisible (False )
- self .posts_search_input .clear ()
- self .globally_selected_post_ids .clear ()
- self .add_selected_button .setEnabled (True )
- self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
-
-
-
-
- def _get_domain_for_service (self ,service_name ):
- """Determines the base domain for a given service."""
- service_lower =service_name .lower ()
- if service_lower in ['onlyfans','fansly']:
- return "coomer.su"
- return "kemono.su"
-
- def _handle_add_selected (self ):
- """Gathers globally selected creators and processes them."""
- selected_display_names =[]
- self .selected_creators_for_queue .clear ()
- for creator_data in self .globally_selected_creators .values ():
- creator_name =creator_data .get ('name')
- self .selected_creators_for_queue .append (creator_data )
- if creator_name :
- selected_display_names .append (creator_name )
-
- if selected_display_names :
- main_app_window =self .parent ()
- if hasattr (main_app_window ,'link_input'):
- main_app_window .link_input .setText (", ".join (selected_display_names ))
- self .accept ()
- else :
- QMessageBox .information (self ,"No Selection","No creators selected to add.")
-
- def _handle_item_check_changed (self ,item ):
- """Updates the globally_selected_creators dict when an item's check state changes."""
- creator_data =item .data (Qt .UserRole )
- if not isinstance (creator_data ,dict ):
- return
-
- service =creator_data .get ('service')
- creator_id =creator_data .get ('id')
-
- if service is None or creator_id is None :
- print (f"Warning: Creator data in list item missing service or id: {creator_data .get ('name')}")
- return
-
- unique_key =(str (service ),str (creator_id ))
-
- if item .checkState ()==Qt .Checked :
- self .globally_selected_creators [unique_key ]=creator_data
- 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 ))
-
-class PostsFetcherThread (QThread ):
- status_update =pyqtSignal (str )
- posts_fetched_signal =pyqtSignal (object ,list )
- fetch_error_signal =pyqtSignal (object ,str )
- finished_signal =pyqtSignal ()
-
- def __init__ (self ,creators_to_fetch ,parent_dialog_ref ):
- super ().__init__ ()
- self .creators_to_fetch =creators_to_fetch
- self .parent_dialog =parent_dialog_ref
- self .cancellation_flag =threading .Event ()
-
- def cancel (self ):
- self .cancellation_flag .set ()
- self .status_update .emit (self .parent_dialog ._tr ("post_fetch_cancelled_status","Post fetching cancellation requested..."))
-
- def run (self ):
- if not self .creators_to_fetch :
- self .status_update .emit (self .parent_dialog ._tr ("no_creators_to_fetch_status","No creators selected to fetch posts for."))
- self .finished_signal .emit ()
- return
-
- for creator_data in self .creators_to_fetch :
- if self .cancellation_flag .is_set ():
- break
-
- creator_name =creator_data .get ('name','Unknown Creator')
- service =creator_data .get ('service')
- user_id =creator_data .get ('id')
-
- if not service or not user_id :
- self .fetch_error_signal .emit (creator_data ,f"Missing service or ID for {creator_name }")
- continue
-
- self .status_update .emit (self .parent_dialog ._tr ("fetching_posts_for_creator_status_all_pages","Fetching all posts for {creator_name} ({service})... This may take a while.").format (creator_name =creator_name ,service =service ))
-
- domain =self .parent_dialog ._get_domain_for_service (service )
- api_url_base =f"https://{domain }/api/v1/{service }/user/{user_id }"
-
-
- use_cookie_param =False
- cookie_text_param =""
- selected_cookie_file_param =None
- app_base_dir_param =None
-
- if self .parent_dialog .parent_app :
- app =self .parent_dialog .parent_app
- use_cookie_param =app .use_cookie_checkbox .isChecked ()
- cookie_text_param =app .cookie_text_input .text ().strip ()
- selected_cookie_file_param =app .selected_cookie_filepath
- app_base_dir_param =app .app_base_dir
-
- all_posts_for_this_creator =[]
- try :
- post_generator =download_from_api (
- api_url_base ,
- logger =lambda msg :self .status_update .emit (f"[API Fetch - {creator_name }] {msg }"),
-
- use_cookie =use_cookie_param ,
- cookie_text =cookie_text_param ,
- selected_cookie_file =selected_cookie_file_param ,
- app_base_dir =app_base_dir_param ,
- manga_filename_style_for_sort_check =None ,
- cancellation_event =self .cancellation_flag
- )
-
- for posts_batch in post_generator :
- if self .cancellation_flag .is_set ():
- self .status_update .emit (f"Post fetching for {creator_name } cancelled during pagination.")
- break
- all_posts_for_this_creator .extend (posts_batch )
- self .status_update .emit (f"Fetched {len (all_posts_for_this_creator )} posts so far for {creator_name }...")
-
- if not self .cancellation_flag .is_set ():
- self .posts_fetched_signal .emit (creator_data ,all_posts_for_this_creator )
- self .status_update .emit (f"Finished fetching {len (all_posts_for_this_creator )} posts for {creator_name }.")
- else :
- self .posts_fetched_signal .emit (creator_data ,all_posts_for_this_creator )
- self .status_update .emit (f"Fetching for {creator_name } cancelled. {len (all_posts_for_this_creator )} posts collected.")
-
- except RuntimeError as e :
- if "cancelled by user"in str (e ).lower ()or self .cancellation_flag .is_set ():
- self .status_update .emit (f"Post fetching for {creator_name } cancelled: {e }")
- self .posts_fetched_signal .emit (creator_data ,all_posts_for_this_creator )
- else :
- self .fetch_error_signal .emit (creator_data ,f"Runtime error fetching posts for {creator_name }: {e }")
- except Exception as e :
- self .fetch_error_signal .emit (creator_data ,f"Error fetching posts for {creator_name }: {e }")
-
- if self .cancellation_flag .is_set ():
- break
- QThread .msleep (200 )
-
- if self .cancellation_flag .is_set ():
- self .status_update .emit (self .parent_dialog ._tr ("post_fetch_cancelled_status_done","Post fetching cancelled."))
- else :
- self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators."))
- self .finished_signal .emit ()
-
-class CookieHelpDialog (QDialog ):
- """A dialog to explain how to get a cookies.txt file."""
- CHOICE_PROCEED_WITHOUT_COOKIES =1
- CHOICE_CANCEL_DOWNLOAD =2
- CHOICE_OK_INFO_ONLY =3
- _is_scrolling_titles =False
- _is_scrolling_dates =False
-
- def __init__ (self ,parent_app ,parent =None ,offer_download_without_option =False ):
- super ().__init__ (parent )
- self .parent_app =parent_app
- self .setModal (True )
- self .offer_download_without_option =offer_download_without_option
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
- self .user_choice =None
- main_layout =QVBoxLayout (self )
-
- self .info_label =QLabel ()
- self .info_label .setTextFormat (Qt .RichText )
- self .info_label .setOpenExternalLinks (True )
- self .info_label .setWordWrap (True )
- main_layout .addWidget (self .info_label )
- button_layout =QHBoxLayout ()
- if self .offer_download_without_option :
- button_layout .addStretch (1 )
-
- self .download_without_button =QPushButton ()
- self .download_without_button .clicked .connect (self ._proceed_without_cookies )
- button_layout .addWidget (self .download_without_button )
-
- self .cancel_button =QPushButton ()
- self .cancel_button .clicked .connect (self ._cancel_download )
- button_layout .addWidget (self .cancel_button )
- else :
- button_layout .addStretch (1 )
- self .ok_button =QPushButton ()
- self .ok_button .clicked .connect (self ._ok_info_only )
- button_layout .addWidget (self .ok_button )
-
- main_layout .addLayout (button_layout )
-
- self ._retranslate_ui ()
-
- if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
- self .setMinimumWidth (500 )
-
- 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 )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("cookie_help_dialog_title","Cookie File Instructions"))
- instruction_html =f"""
- {self ._tr ("cookie_help_instruction_intro","
To use cookies...
")}
- {self ._tr ("cookie_help_how_to_get_title","How to get cookies.txt:
")}
-
- {self ._tr ("cookie_help_step1_extension_intro","- Install extension...
")}
- {self ._tr ("cookie_help_step2_login","- Go to website...
")}
- {self ._tr ("cookie_help_step3_click_icon","- Click icon...
")}
- {self ._tr ("cookie_help_step4_export","- Click export...
")}
- {self ._tr ("cookie_help_step5_save_file","- Save file...
")}
- {self ._tr ("cookie_help_step6_app_intro","- In this application:
")}
- {self ._tr ("cookie_help_step6a_checkbox","- Ensure checkbox...
")}
- {self ._tr ("cookie_help_step6b_browse","- Click browse...
")}
- {self ._tr ("cookie_help_step6c_select","- Select file...
")}
-
- {self ._tr ("cookie_help_alternative_paste","Alternatively, paste...
")}
- """
- self .info_label .setText (instruction_html )
-
- if self .offer_download_without_option :
- self .download_without_button .setText (self ._tr ("cookie_help_proceed_without_button","Download without Cookies"))
- self .cancel_button .setText (self ._tr ("cookie_help_cancel_download_button","Cancel Download"))
- else :
- self .ok_button .setText (self ._tr ("ok_button","OK"))
-
- def _proceed_without_cookies (self ):
- self .user_choice =self .CHOICE_PROCEED_WITHOUT_COOKIES
- self .accept ()
-
- def _cancel_download (self ):
- self .user_choice =self .CHOICE_CANCEL_DOWNLOAD
- self .reject ()
-
- def _ok_info_only (self ):
- self .user_choice =self .CHOICE_OK_INFO_ONLY
- self .accept ()
-
-class DownloadHistoryDialog (QDialog ):
- """Dialog to display download history."""
- def __init__ (self ,last_3_downloaded_entries ,first_processed_entries ,parent_app ,parent =None ):
- super ().__init__ (parent )
- self .parent_app =parent_app
- self .last_3_downloaded_entries =last_3_downloaded_entries
- self .first_processed_entries =first_processed_entries
- self .setModal (True )
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
-
- screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
- scale_factor =screen_height /768.0
- base_min_w ,base_min_h =600 ,450
-
- scaled_min_w =int (base_min_w *1.5 *scale_factor )
- scaled_min_h =int (base_min_h *scale_factor )
- self .setMinimumSize (scaled_min_w ,scaled_min_h )
-
- self .setWindowTitle (self ._tr ("download_history_dialog_title_combined","Download History"))
-
-
- dialog_layout =QVBoxLayout (self )
- self .setLayout (dialog_layout )
-
-
- self .main_splitter =QSplitter (Qt .Horizontal )
- dialog_layout .addWidget (self .main_splitter )
-
-
- left_pane_widget =QWidget ()
- left_layout =QVBoxLayout (left_pane_widget )
- left_header_label =QLabel (self ._tr ("history_last_downloaded_header","Last 3 Files Downloaded:"))
- left_header_label .setAlignment (Qt .AlignCenter )
- left_layout .addWidget (left_header_label )
-
- left_scroll_area =QScrollArea ()
- left_scroll_area .setWidgetResizable (True )
- left_scroll_content_widget =QWidget ()
- left_scroll_layout =QVBoxLayout (left_scroll_content_widget )
-
- if not self .last_3_downloaded_entries :
- no_left_history_label =QLabel (self ._tr ("no_download_history_header","No Downloads Yet"))
- no_left_history_label .setAlignment (Qt .AlignCenter )
- left_scroll_layout .addWidget (no_left_history_label )
- else :
- for entry in self .last_3_downloaded_entries :
- group_box =QGroupBox (f"{self ._tr ('history_file_label','File:')} {entry .get ('disk_filename','N/A')}")
- group_layout =QVBoxLayout (group_box )
- details_text =(
- f"{self ._tr ('history_from_post_label','From Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})
"
- f"{self ._tr ('history_creator_series_label','Creator/Series:')} {entry .get ('creator_display_name','N/A')}
"
- f"{self ._tr ('history_post_uploaded_label','Post Uploaded:')} {entry .get ('upload_date_str','N/A')}
"
- f"{self ._tr ('history_file_downloaded_label','File Downloaded:')} {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_timestamp',0 )))}
"
- f"{self ._tr ('history_saved_in_folder_label','Saved In Folder:')} {entry .get ('download_path','N/A')}"
- )
- details_label =QLabel (details_text )
- details_label .setWordWrap (True )
- details_label .setTextFormat (Qt .RichText )
- group_layout .addWidget (details_label )
- left_scroll_layout .addWidget (group_box )
- left_scroll_area .setWidget (left_scroll_content_widget )
- left_layout .addWidget (left_scroll_area )
- self .main_splitter .addWidget (left_pane_widget )
-
-
- right_pane_widget =QWidget ()
- right_layout =QVBoxLayout (right_pane_widget )
- right_header_label =QLabel (self ._tr ("first_files_processed_header","First {count} Posts Processed This Session:").format (count =len (self .first_processed_entries )))
- right_header_label .setAlignment (Qt .AlignCenter )
- right_layout .addWidget (right_header_label )
-
- right_scroll_area =QScrollArea ()
- right_scroll_area .setWidgetResizable (True )
- right_scroll_content_widget =QWidget ()
- right_scroll_layout =QVBoxLayout (right_scroll_content_widget )
-
- if not self .first_processed_entries :
- no_right_history_label =QLabel (self ._tr ("no_processed_history_header","No Posts Processed Yet"))
- no_right_history_label .setAlignment (Qt .AlignCenter )
- right_scroll_layout .addWidget (no_right_history_label )
- else :
- for entry in self .first_processed_entries :
-
- group_box =QGroupBox (f"{self ._tr ('history_post_label','Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})")
- group_layout =QVBoxLayout (group_box )
- details_text =(
- f"{self ._tr ('history_creator_label','Creator:')} {entry .get ('creator_name','N/A')}
"
- f"{self ._tr ('history_top_file_label','Top File:')} {entry .get ('top_file_name','N/A')}
"
- f"{self ._tr ('history_num_files_label','Num Files in Post:')} {entry .get ('num_files',0 )}
"
- f"{self ._tr ('history_post_uploaded_label','Post Uploaded:')} {entry .get ('upload_date_str','N/A')}
"
- f"{self ._tr ('history_processed_on_label','Processed On:')} {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_date_timestamp',0 )))}
"
- f"{self ._tr ('history_saved_to_folder_label','Saved To Folder:')} {entry .get ('download_location','N/A')}"
- )
- details_label =QLabel (details_text )
- details_label .setWordWrap (True )
- details_label .setTextFormat (Qt .RichText )
- group_layout .addWidget (details_label )
- right_scroll_layout .addWidget (group_box )
- right_scroll_area .setWidget (right_scroll_content_widget )
- right_layout .addWidget (right_scroll_area )
- self .main_splitter .addWidget (right_pane_widget )
-
-
- QTimer .singleShot (0 ,lambda :self .main_splitter .setSizes ([self .width ()//2 ,self .width ()//2 ]))
-
-
- bottom_button_layout =QHBoxLayout ()
- self .save_history_button =QPushButton (self ._tr ("history_save_button_text","Save History to .txt"))
- self .save_history_button .clicked .connect (self ._save_history_to_txt )
- bottom_button_layout .addStretch (1 )
- bottom_button_layout .addWidget (self .save_history_button )
-
- dialog_layout .addLayout (bottom_button_layout )
-
- if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
-
- 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 )
- return default_text
-
- def _save_history_to_txt (self ):
- if not self .last_3_downloaded_entries and not self .first_processed_entries :
- QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"),
- self ._tr ("history_nothing_to_save_message","There is no history to save."))
- return
-
- main_download_dir =self .parent_app .dir_input .text ().strip ()
- default_save_dir =""
- if main_download_dir and os .path .isdir (main_download_dir ):
- default_save_dir =main_download_dir
- else :
- fallback_dir =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation )
- if fallback_dir and os .path .isdir (fallback_dir ):
- default_save_dir =fallback_dir
- else :
- default_save_dir =self .parent_app .app_base_dir
-
- default_filepath =os .path .join (default_save_dir ,"download_history.txt")
-
- filepath ,_ =QFileDialog .getSaveFileName (
- self ,self ._tr ("history_save_dialog_title","Save Download History"),
- default_filepath ,"Text Files (*.txt);;All Files (*)"
- )
-
- if not filepath :
- return
-
- history_content =[]
- history_content .append (f"{self ._tr ('history_last_downloaded_header','Last 3 Files Downloaded:')}\n")
- if self .last_3_downloaded_entries :
- for entry in self .last_3_downloaded_entries :
- history_content .append (f" {self ._tr ('history_file_label','File:')} {entry .get ('disk_filename','N/A')}")
- history_content .append (f" {self ._tr ('history_from_post_label','From Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})")
- history_content .append (f" {self ._tr ('history_creator_series_label','Creator/Series:')} {entry .get ('creator_display_name','N/A')}")
- history_content .append (f" {self ._tr ('history_post_uploaded_label','Post Uploaded:')} {entry .get ('upload_date_str','N/A')}")
- history_content .append (f" {self ._tr ('history_file_downloaded_label','File Downloaded:')} {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_timestamp',0 )))}")
- history_content .append (f" {self ._tr ('history_saved_in_folder_label','Saved In Folder:')} {entry .get ('download_path','N/A')}\n")
- else :
- history_content .append (f" ({self ._tr ('no_download_history_header','No Downloads Yet')})\n")
-
- history_content .append (f"\n{self ._tr ('first_files_processed_header','First {count} Posts Processed This Session:').format (count =len (self .first_processed_entries ))}\n")
- if self .first_processed_entries :
- for entry in self .first_processed_entries :
- history_content .append (f" {self ._tr ('history_post_label','Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})")
- history_content .append (f" {self ._tr ('history_creator_label','Creator:')} {entry .get ('creator_name','N/A')}")
- history_content .append (f" {self ._tr ('history_top_file_label','Top File:')} {entry .get ('top_file_name','N/A')}")
- history_content .append (f" {self ._tr ('history_num_files_label','Num Files in Post:')} {entry .get ('num_files',0 )}")
- history_content .append (f" {self ._tr ('history_post_uploaded_label','Post Uploaded:')} {entry .get ('upload_date_str','N/A')}")
- history_content .append (f" {self ._tr ('history_processed_on_label','Processed On:')} {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_date_timestamp',0 )))}")
- history_content .append (f" {self ._tr ('history_saved_to_folder_label','Saved To Folder:')} {entry .get ('download_location','N/A')}\n")
- else :
- history_content .append (f" ({self ._tr ('no_processed_history_header','No Posts Processed Yet')})\n")
-
- try :
- with open (filepath ,'w',encoding ='utf-8')as f :
- f .write ("\n".join (history_content ))
- QMessageBox .information (self ,self ._tr ("history_export_success_title","History Export Successful"),
- self ._tr ("history_export_success_message","Successfully exported download history to:\n{filepath}").format (filepath =filepath ))
- except Exception as e :
- QMessageBox .critical (self ,self ._tr ("history_export_error_title","History Export Error"),
- self ._tr ("history_export_error_message","Could not export download history: {error}").format (error =str (e )))
-
-class KnownNamesFilterDialog (QDialog ):
- """A dialog to select names from Known.txt to add to the filter input."""
- def __init__ (self ,known_names_list ,parent_app_ref ,parent =None ):
- super ().__init__ (parent )
- self .parent_app =parent_app_ref
- self .setModal (True )
- self .all_known_name_entries =sorted (known_names_list ,key =lambda x :x ['name'].lower ())
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
- self .selected_entries_to_return =[]
-
- main_layout =QVBoxLayout (self )
-
- self .search_input =QLineEdit ()
- self .search_input .textChanged .connect (self ._filter_list_display )
- main_layout .addWidget (self .search_input )
-
- self .names_list_widget =QListWidget ()
- main_layout .addWidget (self .names_list_widget )
-
- buttons_layout =QHBoxLayout ()
-
- self .select_all_button =QPushButton ()
- self .select_all_button .clicked .connect (self ._select_all_items )
- buttons_layout .addWidget (self .select_all_button )
-
- self .deselect_all_button =QPushButton ()
- self .deselect_all_button .clicked .connect (self ._deselect_all_items )
- buttons_layout .addWidget (self .deselect_all_button )
- buttons_layout .addStretch (1 )
-
- self .add_button =QPushButton ()
- self .add_button .clicked .connect (self ._accept_selection_action )
- buttons_layout .addWidget (self .add_button )
-
- self .cancel_button =QPushButton ()
- self .cancel_button .clicked .connect (self .reject )
- buttons_layout .addWidget (self .cancel_button )
- main_layout .addLayout (buttons_layout )
-
- self ._retranslate_ui ()
- self ._populate_list_widget ()
-
-
- screen_geometry =QApplication .primaryScreen ().availableGeometry ()
- base_width ,base_height =460 ,450
-
-
-
- reference_screen_height =1080
- scale_factor_w =screen_geometry .width ()/1920
- scale_factor_h =screen_geometry .height ()/reference_screen_height
-
-
-
-
- effective_scale_factor =max (0.75 ,min (scale_factor_h ,1.5 ))
-
- self .setMinimumSize (int (base_width *effective_scale_factor ),int (base_height *effective_scale_factor ))
- self .resize (int (base_width *effective_scale_factor *1.1 ),int (base_height *effective_scale_factor *1.1 ))
-
- if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
- self .add_button .setDefault (True )
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("known_names_filter_dialog_title","Add Known Names to Filter"))
- self .search_input .setPlaceholderText (self ._tr ("known_names_filter_search_placeholder","Search names..."))
- self .select_all_button .setText (self ._tr ("known_names_filter_select_all_button","Select All"))
- self .deselect_all_button .setText (self ._tr ("known_names_filter_deselect_all_button","Deselect All"))
- self .add_button .setText (self ._tr ("known_names_filter_add_selected_button","Add Selected"))
- self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
-
- def _populate_list_widget (self ,names_to_display =None ):
-
-
- if hasattr (self ,'_items_populated')and self ._items_populated and names_to_display is not None :
-
- return
-
- self .names_list_widget .clear ()
- current_entries_source =names_to_display if names_to_display is not None else self .all_known_name_entries
- for entry_obj in current_entries_source :
- item =QListWidgetItem (entry_obj ['name'])
- item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
- item .setCheckState (Qt .Unchecked )
- item .setData (Qt .UserRole ,entry_obj )
- self .names_list_widget .addItem (item )
- self ._items_populated =True
-
- def _filter_list_display (self ):
- search_text =self .search_input .text ().lower ()
- for i in range (self .names_list_widget .count ()):
- item =self .names_list_widget .item (i )
- entry_obj =item .data (Qt .UserRole )
- matches_search =not search_text or search_text in entry_obj ['name'].lower ()
- item .setHidden (not matches_search )
-
- def _accept_selection_action (self ):
- self .selected_entries_to_return =[]
- for i in range (self .names_list_widget .count ()):
- item =self .names_list_widget .item (i )
- if item .checkState ()==Qt .Checked :
- self .selected_entries_to_return .append (item .data (Qt .UserRole ))
- self .accept ()
-
- def _select_all_items (self ):
- """Checks all items in the list widget."""
- for i in range (self .names_list_widget .count ()):
- self .names_list_widget .item (i ).setCheckState (Qt .Checked )
-
- def _deselect_all_items (self ):
- """Unchecks all items in the list widget."""
- for i in range (self .names_list_widget .count ()):
- self .names_list_widget .item (i ).setCheckState (Qt .Unchecked )
-
- def get_selected_entries (self ):
- return self .selected_entries_to_return
-
-class FavoriteArtistsDialog (QDialog ):
- """Dialog to display and select favorite artists."""
- def __init__ (self ,parent_app ,cookies_config ):
- super ().__init__ (parent_app )
- self .parent_app =parent_app
- self .cookies_config =cookies_config
- self .all_fetched_artists =[]
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
- self .selected_artist_urls =[]
-
- self .setModal (True )
- self .setMinimumSize (500 ,500 )
-
- self ._init_ui ()
- self ._fetch_favorite_artists ()
-
- def _get_domain_for_service (self ,service_name ):
- service_lower =service_name .lower ()
- coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'}
- if service_lower in coomer_primary_services :
- return "coomer.su"
- else :
- return "kemono.su"
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("fav_artists_dialog_title","Favorite Artists"))
- self .status_label .setText (self ._tr ("fav_artists_loading_status","Loading favorite artists..."))
- self .search_input .setPlaceholderText (self ._tr ("fav_artists_search_placeholder","Search artists..."))
- self .select_all_button .setText (self ._tr ("fav_artists_select_all_button","Select All"))
- self .deselect_all_button .setText (self ._tr ("fav_artists_deselect_all_button","Deselect All"))
- self .download_button .setText (self ._tr ("fav_artists_download_selected_button","Download Selected"))
- self .cancel_button .setText (self ._tr ("fav_artists_cancel_button","Cancel"))
-
- def _init_ui (self ):
- main_layout =QVBoxLayout (self )
-
- self .status_label =QLabel ()
- self .status_label .setAlignment (Qt .AlignCenter )
- main_layout .addWidget (self .status_label )
-
- self .search_input =QLineEdit ()
- self .search_input .textChanged .connect (self ._filter_artist_list_display )
- main_layout .addWidget (self .search_input )
-
-
- self .artist_list_widget =QListWidget ()
- self .artist_list_widget .setStyleSheet ("""
- QListWidget::item {
- border-bottom: 1px solid #4A4A4A; /* Slightly softer line */
- padding-top: 4px;
- padding-bottom: 4px;
- }""")
- main_layout .addWidget (self .artist_list_widget )
- self .artist_list_widget .setAlternatingRowColors (True )
- self .search_input .setVisible (False )
- self .artist_list_widget .setVisible (False )
-
- combined_buttons_layout =QHBoxLayout ()
-
- self .select_all_button =QPushButton ()
- self .select_all_button .clicked .connect (self ._select_all_items )
- combined_buttons_layout .addWidget (self .select_all_button )
-
- self .deselect_all_button =QPushButton ()
- self .deselect_all_button .clicked .connect (self ._deselect_all_items )
- combined_buttons_layout .addWidget (self .deselect_all_button )
-
-
- self .download_button =QPushButton ()
- self .download_button .clicked .connect (self ._accept_selection_action )
- self .download_button .setEnabled (False )
- self .download_button .setDefault (True )
- combined_buttons_layout .addWidget (self .download_button )
-
- self .cancel_button =QPushButton ()
- self .cancel_button .clicked .connect (self .reject )
- combined_buttons_layout .addWidget (self .cancel_button )
-
- combined_buttons_layout .addStretch (1 )
- main_layout .addLayout (combined_buttons_layout )
-
- self ._retranslate_ui ()
- if hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
- self .setStyleSheet (self .parent_app .get_dark_theme ())
-
-
- def _logger (self ,message ):
- """Helper to log messages, either to parent app or console."""
- if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
- self .parent_app .log_signal .emit (f"[FavArtistsDialog] {message }")
- else :
- print (f"[FavArtistsDialog] {message }")
-
- def _show_content_elements (self ,show ):
- """Helper to show/hide content-related widgets."""
- self .search_input .setVisible (show )
- self .artist_list_widget .setVisible (show )
-
- def _fetch_favorite_artists (self ):
- kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
- coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
-
- self .all_fetched_artists =[]
- fetched_any_successfully =False
- errors_occurred =[]
- any_cookies_loaded_successfully_for_any_source =False
-
- api_sources =[
- {"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"},
- {"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
- ]
-
- for source in api_sources :
- self ._logger (f"Attempting to fetch favorite artists from: {source ['name']} ({source ['url']})")
- self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
- QCoreApplication .processEvents ()
-
- cookies_dict_for_source =None
- if self .cookies_config ['use_cookie']:
- cookies_dict_for_source =prepare_cookies_for_request (
- True ,
- self .cookies_config ['cookie_text'],
- self .cookies_config ['selected_cookie_file'],
- self .cookies_config ['app_base_dir'],
- self ._logger ,
- target_domain =source ['domain']
- )
- if cookies_dict_for_source :
- any_cookies_loaded_successfully_for_any_source =True
- else :
- self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
- try :
- headers ={'User-Agent':'Mozilla/5.0'}
- response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
- response .raise_for_status ()
- artists_data_from_api =response .json ()
-
- if not isinstance (artists_data_from_api ,list ):
- error_msg =f"Error ({source ['name']}): API did not return a list of artists (got {type (artists_data_from_api )})."
- self ._logger (error_msg )
- errors_occurred .append (error_msg )
- continue
-
- processed_artists_from_source =0
- for artist_entry in artists_data_from_api :
- artist_id =artist_entry .get ("id")
- artist_name =html .unescape (artist_entry .get ("name","Unknown Artist").strip ())
- artist_service_platform =artist_entry .get ("service")
-
- if artist_id and artist_name and artist_service_platform :
- artist_page_domain =self ._get_domain_for_service (artist_service_platform )
- full_url =f"https://{artist_page_domain }/{artist_service_platform }/user/{artist_id }"
-
- self .all_fetched_artists .append ({
- 'name':artist_name ,
- 'url':full_url ,
- 'service':artist_service_platform ,
- 'id':artist_id ,
- '_source_api':source ['name']
- })
- processed_artists_from_source +=1
- else :
- self ._logger (f"Warning ({source ['name']}): Skipping favorite artist entry due to missing data: {artist_entry }")
-
- if processed_artists_from_source >0 :
- fetched_any_successfully =True
- self ._logger (f"Fetched {processed_artists_from_source } artists from {source ['name']}.")
-
- except requests .exceptions .RequestException as e :
- error_msg =f"Error fetching favorites from {source ['name']}: {e }"
- self ._logger (error_msg )
- errors_occurred .append (error_msg )
- except Exception as e :
- error_msg =f"An unexpected error occurred with {source ['name']}: {e }"
- self ._logger (error_msg )
- errors_occurred .append (error_msg )
-
-
- if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
- self .status_label .setText (self ._tr ("fav_artists_cookies_required_status","Error: Cookies enabled but could not be loaded for any source."))
- self ._logger ("Error: Cookies enabled but no cookies loaded for any source. Showing help dialog.")
- cookie_help_dialog =CookieHelpDialog (self )
- cookie_help_dialog .exec_ ()
- self .download_button .setEnabled (False )
- if not fetched_any_successfully :
- errors_occurred .append ("Cookies enabled but could not be loaded for any API source.")
-
- unique_artists_map ={}
- for artist in self .all_fetched_artists :
- key =(artist ['service'].lower (),str (artist ['id']).lower ())
- if key not in unique_artists_map :
- unique_artists_map [key ]=artist
- self .all_fetched_artists =list (unique_artists_map .values ())
-
- self .all_fetched_artists .sort (key =lambda x :x ['name'].lower ())
- self ._populate_artist_list_widget ()
-
- if fetched_any_successfully and self .all_fetched_artists :
- self .status_label .setText (self ._tr ("fav_artists_found_status","Found {count} total favorite artist(s).").format (count =len (self .all_fetched_artists )))
- self ._show_content_elements (True )
- self .download_button .setEnabled (True )
- elif not fetched_any_successfully and not errors_occurred :
- self .status_label .setText (self ._tr ("fav_artists_none_found_status","No favorite artists found on Kemono.su or Coomer.su."))
- self ._show_content_elements (False )
- self .download_button .setEnabled (False )
- else :
- final_error_message =self ._tr ("fav_artists_failed_status","Failed to fetch favorites.")
- if errors_occurred :
- final_error_message +=" Errors: "+"; ".join (errors_occurred )
- self .status_label .setText (final_error_message )
- self ._show_content_elements (False )
- self .download_button .setEnabled (False )
- if fetched_any_successfully and not self .all_fetched_artists :
- self .status_label .setText (self ._tr ("fav_artists_no_favorites_after_processing","No favorite artists found after processing."))
-
- def _populate_artist_list_widget (self ,artists_to_display =None ):
- self .artist_list_widget .clear ()
- source_list =artists_to_display if artists_to_display is not None else self .all_fetched_artists
- for artist_data in source_list :
- item =QListWidgetItem (f"{artist_data ['name']} ({artist_data .get ('service','N/A').capitalize ()})")
- item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
- item .setCheckState (Qt .Unchecked )
- item .setData (Qt .UserRole ,artist_data )
- self .artist_list_widget .addItem (item )
-
- def _filter_artist_list_display (self ):
- search_text =self .search_input .text ().lower ().strip ()
- if not search_text :
- self ._populate_artist_list_widget ()
- return
-
- filtered_artists =[
- artist for artist in self .all_fetched_artists
- if search_text in artist ['name'].lower ()or search_text in artist ['url'].lower ()
- ]
- self ._populate_artist_list_widget (filtered_artists )
-
- def _select_all_items (self ):
- for i in range (self .artist_list_widget .count ()):
- self .artist_list_widget .item (i ).setCheckState (Qt .Checked )
-
- def _deselect_all_items (self ):
- for i in range (self .artist_list_widget .count ()):
- self .artist_list_widget .item (i ).setCheckState (Qt .Unchecked )
-
- def _accept_selection_action (self ):
- self .selected_artists_data =[]
- for i in range (self .artist_list_widget .count ()):
- item =self .artist_list_widget .item (i )
- if item .checkState ()==Qt .Checked :
- self .selected_artists_data .append (item .data (Qt .UserRole ))
-
- if not self .selected_artists_data :
- QMessageBox .information (self ,"No Selection","Please select at least one artist to download.")
- return
- self .accept ()
-
- def get_selected_artists (self ):
- return self .selected_artists_data
-
-class FavoritePostsFetcherThread (QThread ):
- """Worker thread to fetch favorite posts and creator names."""
- status_update =pyqtSignal (str )
- progress_bar_update =pyqtSignal (int ,int )
-
-
-
-
-
-
-
-
-
- finished =pyqtSignal (list ,str )
-
- def __init__ (self ,cookies_config ,parent_logger_func ,target_domain_preference =None ):
- super ().__init__ ()
- self .cookies_config =cookies_config
- self .parent_logger_func =parent_logger_func
- self .target_domain_preference =target_domain_preference
- self .cancellation_event =threading .Event ()
- self .error_key_map ={
- "Kemono.su":"kemono_su",
- "Coomer.su":"coomer_su"
- }
-
- def _logger (self ,message ):
- self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
-
- def run (self ):
- kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post"
- coomer_fav_posts_url ="https://coomer.su/api/v1/account/favorites?type=post"
-
- all_fetched_posts_temp =[]
- error_messages_for_summary =[]
- fetched_any_successfully =False
- any_cookies_loaded_successfully_for_any_source =False
-
- self .status_update .emit ("key_fetching_fav_post_list_init")
- self .progress_bar_update .emit (0 ,0 )
-
- api_sources =[
- {"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"},
- {"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"}
- ]
-
- api_sources_to_try =[]
- if self .target_domain_preference :
- self ._logger (f"Targeting specific domain for favorites: {self .target_domain_preference }")
- for source_def in api_sources :
- if source_def ["domain"]==self .target_domain_preference :
- api_sources_to_try .append (source_def )
- break
- if not api_sources_to_try :
- self ._logger (f"Warning: Preferred domain '{self .target_domain_preference }' not a recognized API source. Fetching from all.")
- api_sources_to_try =api_sources
- else :
- self ._logger ("No specific domain preference, or both domains have cookies. Will attempt to fetch from all sources.")
- api_sources_to_try =api_sources
-
- for source in api_sources_to_try :
- if self .cancellation_event .is_set ():
- self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
- return
- cookies_dict_for_source =None
- if self .cookies_config ['use_cookie']:
- cookies_dict_for_source =prepare_cookies_for_request (
- True ,
- self .cookies_config ['cookie_text'],
- self .cookies_config ['selected_cookie_file'],
- self .cookies_config ['app_base_dir'],
- self ._logger ,
- target_domain =source ['domain']
- )
- if cookies_dict_for_source :
- any_cookies_loaded_successfully_for_any_source =True
- else :
- self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
-
- self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})")
- source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
- self .status_update .emit (f"key_fetching_from_source_{source_key_part }")
- QCoreApplication .processEvents ()
-
- try :
- headers ={'User-Agent':'Mozilla/5.0'}
- response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
- response .raise_for_status ()
- posts_data_from_api =response .json ()
-
- if not isinstance (posts_data_from_api ,list ):
- err_detail =f"Error ({source ['name']}): API did not return a list of posts (got {type (posts_data_from_api )})."
- self ._logger (err_detail )
- error_messages_for_summary .append (err_detail )
- continue
-
- processed_posts_from_source =0
- for post_entry in posts_data_from_api :
- post_id =post_entry .get ("id")
- post_title =html .unescape (post_entry .get ("title","Untitled Post").strip ())
- service =post_entry .get ("service")
- creator_id =post_entry .get ("user")
- added_date_str =post_entry .get ("added",post_entry .get ("published",""))
-
- if post_id and post_title and service and creator_id :
- all_fetched_posts_temp .append ({
- 'post_id':post_id ,'title':post_title ,'service':service ,
- 'creator_id':creator_id ,'added_date':added_date_str ,
- '_source_api':source ['name']
- })
- processed_posts_from_source +=1
- else :
- self ._logger (f"Warning ({source ['name']}): Skipping favorite post entry due to missing data: {post_entry }")
-
- if processed_posts_from_source >0 :
- fetched_any_successfully =True
- self ._logger (f"Fetched {processed_posts_from_source } posts from {source ['name']}.")
-
- except requests .exceptions .RequestException as e :
- err_detail =f"Error fetching favorite posts from {source ['name']}: {e }"
- self ._logger (err_detail )
- error_messages_for_summary .append (err_detail )
- if e .response is not None and e .response .status_code ==401 :
- self .finished .emit ([],"KEY_AUTH_FAILED")
- self ._logger (f"Authorization failed for {source ['name']}, emitting KEY_AUTH_FAILED.")
- return
- except Exception as e :
- err_detail =f"An unexpected error occurred with {source ['name']}: {e }"
- self ._logger (err_detail )
- error_messages_for_summary .append (err_detail )
-
- if self .cancellation_event .is_set ():
- self .finished .emit ([],"KEY_FETCH_CANCELLED_AFTER")
- return
-
-
- if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
-
- if self .target_domain_preference and not any_cookies_loaded_successfully_for_any_source :
-
- domain_key_part =self .error_key_map .get (self .target_domain_preference ,self .target_domain_preference .lower ().replace ('.','_'))
- self .finished .emit ([],f"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_{domain_key_part }")
- return
-
-
- self .finished .emit ([],"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC")
- return
-
- unique_posts_map ={}
- for post in all_fetched_posts_temp :
- key =(post ['service'].lower (),str (post ['creator_id']).lower (),str (post ['post_id']).lower ())
- if key not in unique_posts_map :
- unique_posts_map [key ]=post
- all_fetched_posts_temp =list (unique_posts_map .values ())
-
- all_fetched_posts_temp .sort (key =lambda x :(x .get ('_source_api','').lower (),x .get ('service','').lower (),str (x .get ('creator_id','')).lower (),(x .get ('added_date')or '')),reverse =False )
-
- if error_messages_for_summary :
- error_summary_str ="; ".join (error_messages_for_summary )
- if not fetched_any_successfully :
- self .finished .emit ([],f"KEY_FETCH_FAILED_GENERIC_{error_summary_str [:50 ]}")
- else :
- self .finished .emit (all_fetched_posts_temp ,f"KEY_FETCH_PARTIAL_SUCCESS_{error_summary_str [:50 ]}")
- elif not all_fetched_posts_temp and not fetched_any_successfully and not self .target_domain_preference :
- self .finished .emit ([],"KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS")
- else :
- self .finished .emit (all_fetched_posts_temp ,"KEY_FETCH_SUCCESS")
-
-class PostListItemWidget (QWidget ):
- """Custom widget for displaying a single post in the FavoritePostsDialog list."""
- def __init__ (self ,post_data_dict ,parent_dialog_ref ,parent =None ):
- super ().__init__ (parent )
- self .post_data =post_data_dict
- self .parent_dialog =parent_dialog_ref
-
- self .layout =QHBoxLayout (self )
- self .layout .setContentsMargins (5 ,3 ,5 ,3 )
- self .layout .setSpacing (10 )
-
- self .checkbox =QCheckBox ()
- self .layout .addWidget (self .checkbox )
-
- self .info_label =QLabel ()
- self .info_label .setWordWrap (True )
- self .info_label .setTextFormat (Qt .RichText )
- self .layout .addWidget (self .info_label ,1 )
-
- self ._setup_display_text ()
- def _setup_display_text (self ):
- suffix_plain =self .post_data .get ('suffix_for_display',"")
- title_plain =self .post_data .get ('title','Untitled Post')
- escaped_suffix =html .escape (suffix_plain )
- escaped_title =html .escape (title_plain )
- p_style_paragraph ="font-size:10.5pt; margin:0; padding:0;"
- title_span_style ="font-weight:bold; color:#E0E0E0;"
- suffix_span_style ="color:#999999; font-weight:normal; font-size:9.5pt;"
-
- if escaped_suffix :
- display_html_content =f"{escaped_title }{escaped_suffix }
"
- else :
- display_html_content =f"{escaped_title }
"
-
- self .info_label .setText (display_html_content )
-
- def isChecked (self ):return self .checkbox .isChecked ()
- def setCheckState (self ,state ):self .checkbox .setCheckState (state )
- def get_post_data (self ):return self .post_data
-
-class FavoritePostsDialog (QDialog ):
- """Dialog to display and select favorite posts."""
- def __init__ (self ,parent_app ,cookies_config ,known_names_list_ref ,target_domain_preference =None ):
- super ().__init__ (parent_app )
- self .parent_app =parent_app
- self .cookies_config =cookies_config
- self .all_fetched_posts =[]
- self .selected_posts_data =[]
- self .known_names_list_ref =known_names_list_ref
- self .target_domain_preference_for_this_fetch =target_domain_preference
- self .creator_name_cache ={}
- self .displayable_grouped_posts ={}
- self .fetcher_thread =None
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
-
- self .setModal (True )
- self .setMinimumSize (600 ,600 )
- if hasattr (self .parent_app ,'get_dark_theme'):
- self .setStyleSheet (self .parent_app .get_dark_theme ())
-
- self ._init_ui ()
- self ._load_creator_names_from_file ()
- self ._retranslate_ui ()
- self ._start_fetching_favorite_posts ()
-
- def _update_status_label_from_key (self ,status_key ):
- """Translates a status key and updates the status label."""
-
- translated_status =self ._tr (status_key .lower (),status_key )
- self .status_label .setText (translated_status )
-
- def _init_ui (self ):
- main_layout =QVBoxLayout (self )
-
- self .status_label =QLabel ()
- self .status_label .setAlignment (Qt .AlignCenter )
- main_layout .addWidget (self .status_label )
-
- self .progress_bar =QProgressBar ()
- self .progress_bar .setTextVisible (False )
- self .progress_bar .setVisible (False )
- main_layout .addWidget (self .progress_bar )
-
- self .search_input =QLineEdit ()
-
- self .search_input .textChanged .connect (self ._filter_post_list_display )
- main_layout .addWidget (self .search_input )
-
- self .post_list_widget =QListWidget ()
- self .post_list_widget .setStyleSheet ("""
- QListWidget::item {
- border-bottom: 1px solid #4A4A4A;
- padding-top: 4px;
- padding-bottom: 4px;
- }""")
- self .post_list_widget .setAlternatingRowColors (True )
- main_layout .addWidget (self .post_list_widget )
-
- combined_buttons_layout =QHBoxLayout ()
- self .select_all_button =QPushButton ()
- self .select_all_button .clicked .connect (self ._select_all_items )
- combined_buttons_layout .addWidget (self .select_all_button )
-
- self .deselect_all_button =QPushButton ()
- self .deselect_all_button .clicked .connect (self ._deselect_all_items )
- combined_buttons_layout .addWidget (self .deselect_all_button )
-
- self .download_button =QPushButton ()
- self .download_button .clicked .connect (self ._accept_selection_action )
- self .download_button .setEnabled (False )
- self .download_button .setDefault (True )
- combined_buttons_layout .addWidget (self .download_button )
-
- self .cancel_button =QPushButton ()
- self .cancel_button .clicked .connect (self .reject )
- combined_buttons_layout .addWidget (self .cancel_button )
- combined_buttons_layout .addStretch (1 )
- main_layout .addLayout (combined_buttons_layout )
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
- def _retranslate_ui (self ):
- self .setWindowTitle (self ._tr ("fav_posts_dialog_title","Favorite Posts"))
- self .status_label .setText (self ._tr ("fav_posts_loading_status","Loading favorite posts..."))
- self .search_input .setPlaceholderText (self ._tr ("fav_posts_search_placeholder","Search posts (title, creator name, ID, service)..."))
- self .select_all_button .setText (self ._tr ("fav_posts_select_all_button","Select All"))
- self .deselect_all_button .setText (self ._tr ("fav_posts_deselect_all_button","Deselect All"))
- self .download_button .setText (self ._tr ("fav_posts_download_selected_button","Download Selected"))
- self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
-
- def _logger (self ,message ):
- if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
- self .parent_app .log_signal .emit (f"[FavPostsDialog] {message }")
- else :
- print (f"[FavPostsDialog] {message }")
-
- def _load_creator_names_from_file (self ):
- """Loads creator id-name-service mappings from creators.txt."""
- self ._logger ("Attempting to load creators.json for Favorite Posts Dialog.")
-
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
- base_path_for_creators =sys ._MEIPASS
- self ._logger (f" Running bundled. Using _MEIPASS: {base_path_for_creators }")
- else :
- base_path_for_creators =self .parent_app .app_base_dir
- self ._logger (f" Not bundled or _MEIPASS unavailable. Using app_base_dir: {base_path_for_creators }")
-
- creators_file_path =os .path .join (base_path_for_creators ,"creators.json")
- self ._logger (f"Full path to creators.json: {creators_file_path }")
-
- if not os .path .exists (creators_file_path ):
- self ._logger (f"Warning: 'creators.json' not found at {creators_file_path }. Creator names will not be displayed.")
- return
-
- try :
- with open (creators_file_path ,'r',encoding ='utf-8')as f :
- loaded_data =json .load (f )
-
- if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ):
- creators_list =loaded_data [0 ]
- elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ):
- creators_list =loaded_data
- else :
- self ._logger (f"Warning: 'creators.json' has an unexpected format. Expected a list of lists or a flat list of creator objects.")
- return
-
- for creator_data in creators_list :
- creator_id =creator_data .get ("id")
- name =creator_data .get ("name")
- service =creator_data .get ("service")
- if creator_id and name and service :
- self .creator_name_cache [(service .lower (),str (creator_id ))]=name
- self ._logger (f"Successfully loaded {len (self .creator_name_cache )} creator names from 'creators.json'.")
- except Exception as e :
- self ._logger (f"Error loading 'creators.json': {e }")
-
- def _start_fetching_favorite_posts (self ):
- self .download_button .setEnabled (False )
- self .status_label .setText ("Initializing favorite posts fetch...")
-
- self .fetcher_thread =FavoritePostsFetcherThread (
- self .cookies_config ,
- self .parent_app .log_signal .emit ,
- target_domain_preference =self .target_domain_preference_for_this_fetch
- )
- self .fetcher_thread .status_update .connect (self ._update_status_label_from_key )
- self .fetcher_thread .finished .connect (self ._on_fetch_completed )
- self .fetcher_thread .progress_bar_update .connect (self ._set_progress_bar_value )
- self .progress_bar .setVisible (True )
- self .fetcher_thread .start ()
-
- def _set_progress_bar_value (self ,value ,maximum ):
- if maximum ==0 :
- self .progress_bar .setRange (0 ,0 )
- self .progress_bar .setValue (0 )
- else :
- self .progress_bar .setRange (0 ,maximum )
- self .progress_bar .setValue (value )
-
- def _on_fetch_completed (self ,fetched_posts_list ,status_key ):
- self .progress_bar .setVisible (False )
-
- proceed_to_display_posts =False
- show_error_message_box =False
- message_box_title_key ="fav_posts_fetch_error_title"
- message_box_text_key ="fav_posts_fetch_error_message"
- message_box_params ={'domain':self .target_domain_preference_for_this_fetch or "platform",'error_message_part':""}
- status_label_text_key =None
-
- if status_key =="KEY_FETCH_SUCCESS":
- proceed_to_display_posts =True
- elif status_key and status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_")and fetched_posts_list :
- displayable_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","").replace ("_"," ")
- self ._logger (f"Partial success with posts: {status_key } -> {displayable_detail }")
-
-
- proceed_to_display_posts =True
- elif status_key :
- specific_domain_msg_part =f" for {self .target_domain_preference_for_this_fetch }"if self .target_domain_preference_for_this_fetch else ""
-
- if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC":
- status_label_text_key ="fav_posts_cookies_required_error"
- self ._logger (f"Cookie error: {status_key }. Showing help dialog.")
- cookie_help_dialog =CookieHelpDialog (self )
- cookie_help_dialog .exec_ ()
- elif status_key =="KEY_AUTH_FAILED":
- status_label_text_key ="fav_posts_auth_failed_title"
- self ._logger (f"Auth error: {status_key }. Showing help dialog.")
- QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"),
- self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part ))
- cookie_help_dialog =CookieHelpDialog (self )
- cookie_help_dialog .exec_ ()
- elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
- status_label_text_key ="fav_posts_no_posts_found_status"
- self ._logger (status_key )
- elif status_key .startswith ("KEY_FETCH_CANCELLED"):
- status_label_text_key ="fav_posts_fetch_cancelled_status"
- self ._logger (status_key )
- else :
- displayable_error_detail =status_key
- if status_key .startswith ("KEY_FETCH_FAILED_GENERIC_"):
- displayable_error_detail =status_key .replace ("KEY_FETCH_FAILED_GENERIC_","").replace ("_"," ")
- elif status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_"):
- displayable_error_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","Partial success but no posts: ").replace ("_"," ")
-
- message_box_params ['error_message_part']=f":\n\n{displayable_error_detail }"if displayable_error_detail else ""
- status_label_text_key ="fav_posts_fetch_error_message"
- show_error_message_box =True
- self ._logger (f"Fetch error: {status_key } -> {displayable_error_detail }")
-
- if status_label_text_key :
- self .status_label .setText (self ._tr (status_label_text_key ,status_label_text_key ).format (**message_box_params ))
- if show_error_message_box :
- QMessageBox .critical (self ,self ._tr (message_box_title_key ),self ._tr (message_box_text_key ).format (**message_box_params ))
-
- self .download_button .setEnabled (False )
- return
-
-
- if not proceed_to_display_posts :
- if not status_label_text_key :
- self .status_label .setText (self ._tr ("fav_posts_cookies_required_error","Error: Cookies are required for favorite posts but could not be loaded."))
- self .download_button .setEnabled (False )
- return
-
- if not self .creator_name_cache :
- self ._logger ("Warning: Creator name cache is empty. Names will not be resolved from creators.json. Displaying IDs instead.")
- else :
- self ._logger (f"Creator name cache has {len (self .creator_name_cache )} entries. Attempting to resolve names...")
- sample_keys =list (self .creator_name_cache .keys ())[:3 ]
- if sample_keys :
- self ._logger (f"Sample keys from creator_name_cache: {sample_keys }")
-
-
- processed_one_missing_log =False
- for post_entry in fetched_posts_list :
- service_from_post =post_entry .get ('service','')
- creator_id_from_post =post_entry .get ('creator_id','')
-
- lookup_key_service =service_from_post .lower ()
- lookup_key_id =str (creator_id_from_post )
- lookup_key_tuple =(lookup_key_service ,lookup_key_id )
-
- resolved_name =self .creator_name_cache .get (lookup_key_tuple )
-
- if resolved_name :
- post_entry ['creator_name_resolved']=resolved_name
- else :
- post_entry ['creator_name_resolved']=str (creator_id_from_post )
- if not processed_one_missing_log and self .creator_name_cache :
- self ._logger (f"Debug: Name not found for key {lookup_key_tuple }. Using ID '{creator_id_from_post }'.")
- processed_one_missing_log =True
-
- self .all_fetched_posts =fetched_posts_list
-
- if not self .all_fetched_posts :
- self .status_label .setText (self ._tr ("fav_posts_no_posts_found_status","No favorite posts found."))
- self .download_button .setEnabled (False )
- return
-
- try :
- self ._populate_post_list_widget ()
- self .status_label .setText (self ._tr ("fav_posts_found_status","{count} favorite post(s) found.").format (count =len (self .all_fetched_posts )))
- self .download_button .setEnabled (True )
- except Exception as e :
- self .status_label .setText (self ._tr ("fav_posts_display_error_status","Error displaying posts: {error}").format (error =str (e )))
- self ._logger (f"Error during _populate_post_list_widget: {e }\n{traceback .format_exc (limit =3 )}")
- QMessageBox .critical (self ,self ._tr ("fav_posts_ui_error_title","UI Error"),self ._tr ("fav_posts_ui_error_message","Could not display favorite posts: {error}").format (error =str (e )))
- self .download_button .setEnabled (False )
-
-
- def _find_best_known_name_match_in_title (self ,title_raw ):
- if not title_raw or not self .known_names_list_ref :
- return None
-
- title_lower =title_raw .lower ()
- best_match_known_name_primary =None
- longest_match_len =0
-
- for known_entry in self .known_names_list_ref :
- aliases_to_check =set ()
- for alias_val in known_entry .get ("aliases",[]):
- aliases_to_check .add (alias_val )
- if not known_entry .get ("is_group",False ):
- aliases_to_check .add (known_entry ["name"])
- sorted_aliases_for_entry =sorted (list (aliases_to_check ),key =len ,reverse =True )
-
- for alias in sorted_aliases_for_entry :
- alias_lower =alias .lower ()
- if not alias_lower :
- continue
- if re .search (r'\b'+re .escape (alias_lower )+r'\b',title_lower ):
- if len (alias_lower )>longest_match_len :
- longest_match_len =len (alias_lower )
- best_match_known_name_primary =known_entry ["name"]
- break
- return best_match_known_name_primary
-
- def _populate_post_list_widget (self ,posts_to_display =None ):
- self .post_list_widget .clear ()
-
- source_list_for_grouping =posts_to_display if posts_to_display is not None else self .all_fetched_posts
- grouped_posts ={}
- for post in source_list_for_grouping :
- service =post .get ('service','unknown_service')
- creator_id =post .get ('creator_id','unknown_id')
- group_key =(service ,creator_id )
- if group_key not in grouped_posts :
- grouped_posts [group_key ]=[]
- grouped_posts [group_key ].append (post )
-
- sorted_group_keys =sorted (grouped_posts .keys (),key =lambda x :(x [0 ].lower (),x [1 ].lower ()))
-
- self .displayable_grouped_posts ={
- key :sorted (grouped_posts [key ],key =lambda p :(p .get ('added_date')or ''),reverse =True )
- for key in sorted_group_keys
- }
- for service ,creator_id_val in sorted_group_keys :
- creator_name_display =self .creator_name_cache .get (
- (service .lower (),str (creator_id_val )),
- str (creator_id_val )
- )
- artist_header_display_text =f"{creator_name_display } ({service .capitalize ()} / {creator_id_val })"
- artist_header_item =QListWidgetItem (f"🎨 {artist_header_display_text }")
- artist_header_item .setFlags (Qt .NoItemFlags )
- font =artist_header_item .font ()
- font .setBold (True )
- font .setPointSize (font .pointSize ()+1 )
- artist_header_item .setFont (font )
- artist_header_item .setForeground (Qt .cyan )
- self .post_list_widget .addItem (artist_header_item )
- for post_data in self .displayable_grouped_posts [(service ,creator_id_val )]:
- post_title_raw =post_data .get ('title','Untitled Post')
- found_known_name_primary =self ._find_best_known_name_match_in_title (post_title_raw )
-
- plain_text_title_for_list_item =post_title_raw
- if found_known_name_primary :
- suffix_text =f" [Known - {found_known_name_primary }]"
- post_data ['suffix_for_display']=suffix_text
- plain_text_title_for_list_item =post_title_raw +suffix_text
- else :
- post_data .pop ('suffix_for_display',None )
-
- list_item =QListWidgetItem (self .post_list_widget )
- list_item .setText (plain_text_title_for_list_item )
- list_item .setFlags (list_item .flags ()|Qt .ItemIsUserCheckable )
- list_item .setCheckState (Qt .Unchecked )
- list_item .setData (Qt .UserRole ,post_data )
- self .post_list_widget .addItem (list_item )
-
- def _filter_post_list_display (self ):
- search_text =self .search_input .text ().lower ().strip ()
- if not search_text :
- self ._populate_post_list_widget (self .all_fetched_posts )
- return
-
- filtered_posts_to_group =[]
- for post in self .all_fetched_posts :
- matches_post_title =search_text in post .get ('title','').lower ()
- matches_creator_name =search_text in post .get ('creator_name_resolved','').lower ()
- matches_creator_id =search_text in post .get ('creator_id','').lower ()
- matches_service =search_text in post ['service'].lower ()
-
- if matches_post_title or matches_creator_name or matches_creator_id or matches_service :
- filtered_posts_to_group .append (post )
-
- self ._populate_post_list_widget (filtered_posts_to_group )
-
- def _select_all_items (self ):
- for i in range (self .post_list_widget .count ()):
- item =self .post_list_widget .item (i )
- if item and item .flags ()&Qt .ItemIsUserCheckable :
- item .setCheckState (Qt .Checked )
-
- def _deselect_all_items (self ):
- for i in range (self .post_list_widget .count ()):
- item =self .post_list_widget .item (i )
- if item and item .flags ()&Qt .ItemIsUserCheckable :
- item .setCheckState (Qt .Unchecked )
-
- def _accept_selection_action (self ):
- self .selected_posts_data =[]
- for i in range (self .post_list_widget .count ()):
- item =self .post_list_widget .item (i )
- if item and item .checkState ()==Qt .Checked :
- post_data_for_download =item .data (Qt .UserRole )
- self .selected_posts_data .append (post_data_for_download )
-
- if not self .selected_posts_data :
- QMessageBox .information (self ,self ._tr ("fav_posts_no_selection_title","No Selection"),self ._tr ("fav_posts_no_selection_message","Please select at least one post to download."))
- return
- self .accept ()
-
- def get_selected_posts (self ):
- return self .selected_posts_data
-
-
-class HelpGuideDialog (QDialog ):
- """A multi-page dialog for displaying the feature guide."""
- def __init__ (self ,steps_data ,parent_app ,parent =None ):
- super ().__init__ (parent )
- self .current_step =0
- self .steps_data =steps_data
- self .parent_app =parent_app
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
- else :
- self .setWindowIcon (QIcon ())
- self .setWindowIcon (app_icon )
-
- self .setModal (True )
- self .setFixedSize (650 ,600 )
-
-
- current_theme_style =""
- if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
- if hasattr (self .parent_app ,'get_dark_theme'):
- current_theme_style =self .parent_app .get_dark_theme ()
-
-
- self .setStyleSheet (current_theme_style if current_theme_style else """
- QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
- QLabel { color: #E0E0E0; }
- QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
- QPushButton:hover { background-color: #656565; }
- QPushButton:pressed { background-color: #4A4A4A; }
- """)
- self ._init_ui ()
- if self .parent_app :
- self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
-
- def _init_ui (self ):
- main_layout =QVBoxLayout (self )
- main_layout .setContentsMargins (0 ,0 ,0 ,0 )
- main_layout .setSpacing (0 )
-
- self .stacked_widget =QStackedWidget ()
- main_layout .addWidget (self .stacked_widget ,1 )
-
- self .tour_steps_widgets =[]
- for title ,content in self .steps_data :
- step_widget =TourStepWidget (title ,content )
- self .tour_steps_widgets .append (step_widget )
- self .stacked_widget .addWidget (step_widget )
-
- self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
-
- buttons_layout =QHBoxLayout ()
- buttons_layout .setContentsMargins (15 ,10 ,15 ,15 )
- buttons_layout .setSpacing (10 )
-
- self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
- self .back_button .clicked .connect (self ._previous_step )
- self .back_button .setEnabled (False )
-
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
- assets_base_dir =sys ._MEIPASS
- else :
- assets_base_dir =os .path .dirname (os .path .abspath (__file__ ))
-
- github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
- instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png")
- discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png")
-
- self .github_button =QPushButton (QIcon (github_icon_path ),"")
- self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
- self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
-
- icon_size =QSize (24 ,24 )
- self .github_button .setIconSize (icon_size )
- self .instagram_button .setIconSize (icon_size )
- self .Discord_button .setIconSize (icon_size )
-
- self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
- self .next_button .clicked .connect (self ._next_step_action )
- self .next_button .setDefault (True )
- self .github_button .clicked .connect (self ._open_github_link )
- self .instagram_button .clicked .connect (self ._open_instagram_link )
- self .Discord_button .clicked .connect (self ._open_Discord_link )
- self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)"))
- self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)"))
- self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)"))
-
-
- social_layout =QHBoxLayout ()
- social_layout .setSpacing (10 )
- social_layout .addWidget (self .github_button )
- social_layout .addWidget (self .instagram_button )
- social_layout .addWidget (self .Discord_button )
-
- while buttons_layout .count ():
- item =buttons_layout .takeAt (0 )
- if item .widget ():
- item .widget ().setParent (None )
- elif item .layout ():
- pass
- buttons_layout .addLayout (social_layout )
- buttons_layout .addStretch (1 )
- buttons_layout .addWidget (self .back_button )
- buttons_layout .addWidget (self .next_button )
- main_layout .addLayout (buttons_layout )
- self ._update_button_states ()
-
- def _next_step_action (self ):
- if self .current_step 0 :
- self .current_step -=1
- self .stacked_widget .setCurrentIndex (self .current_step )
- self ._update_button_states ()
-
- def _update_button_states (self ):
- if self .current_step ==len (self .tour_steps_widgets )-1 :
- self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
- else :
- self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
- self .back_button .setEnabled (self .current_step >0 )
-
- def _open_github_link (self ):
- QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587"))
-
- def _open_instagram_link (self ):
- QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/"))
-
- def _open_Discord_link (self ):
- QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN"))
-
-class TourStepWidget (QWidget ):
- """A single step/page in the tour."""
- def __init__ (self ,title_text ,content_text ,parent =None ):
- super ().__init__ (parent )
- layout =QVBoxLayout (self )
- layout .setContentsMargins (20 ,20 ,20 ,20 )
- layout .setSpacing (10 )
-
- title_label =QLabel (title_text )
- title_label .setAlignment (Qt .AlignCenter )
- title_label .setStyleSheet ("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
- layout .addWidget (title_label )
- scroll_area =QScrollArea ()
- scroll_area .setWidgetResizable (True )
- scroll_area .setFrameShape (QFrame .NoFrame )
- scroll_area .setHorizontalScrollBarPolicy (Qt .ScrollBarAlwaysOff )
- scroll_area .setVerticalScrollBarPolicy (Qt .ScrollBarAsNeeded )
- scroll_area .setStyleSheet ("background-color: transparent;")
-
- content_label =QLabel (content_text )
- content_label .setWordWrap (True )
- content_label .setAlignment (Qt .AlignLeft |Qt .AlignTop )
- content_label .setTextFormat (Qt .RichText )
- content_label .setStyleSheet ("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
- scroll_area .setWidget (content_label )
- layout .addWidget (scroll_area ,1 )
-
-
-class TourDialog (QDialog ):
- """
- A dialog that shows a multi-page tour to the user.
- Includes a "Never show again" checkbox.
- Uses QSettings to remember this preference.
- """
- tour_finished_normally =pyqtSignal ()
- tour_skipped =pyqtSignal ()
-
- CONFIG_ORGANIZATION_NAME ="KemonoDownloader"
- CONFIG_APP_NAME_TOUR ="ApplicationTour"
- TOUR_SHOWN_KEY ="neverShowTourAgainV19"
-
- def __init__ (self ,parent =None ):
- super ().__init__ (parent )
- self .settings =QSettings (self .CONFIG_ORGANIZATION_NAME ,self .CONFIG_APP_NAME_TOUR )
- self .current_step =0
- self .parent_app =parent
-
- app_icon =get_app_icon_object ()
- if not app_icon .isNull ():
- self .setWindowIcon (app_icon )
-
- self .setModal (True )
- self .setFixedSize (600 ,620 )
- self .setStyleSheet ("""
- QDialog {
- background-color: #2E2E2E;
- border: 1px solid #5A5A5A;
- }
- QLabel {
- color: #E0E0E0;
- }
- QCheckBox {
- color: #C0C0C0;
- font-size: 10pt;
- spacing: 5px;
- }
- QCheckBox::indicator {
- width: 13px;
- height: 13px;
- }
- QPushButton {
- background-color: #555;
- color: #F0F0F0;
- border: 1px solid #6A6A6A;
- padding: 8px 15px;
- border-radius: 4px;
- min-height: 25px;
- font-size: 11pt;
- }
- QPushButton:hover {
- background-color: #656565;
- }
- QPushButton:pressed {
- background-color: #4A4A4A;
- }
- """)
- self ._init_ui ()
- self ._center_on_screen ()
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language."""
- if callable (get_translation )and self .parent_app :
- return get_translation (self .parent_app .current_selected_language ,key ,default_text )
- return default_text
-
-
- def _center_on_screen (self ):
- """Centers the dialog on the screen."""
- try :
- primary_screen =QApplication .primaryScreen ()
- if not primary_screen :
- screens =QApplication .screens ()
- if not screens :return
- primary_screen =screens [0 ]
-
- available_geo =primary_screen .availableGeometry ()
- widget_geo =self .frameGeometry ()
-
- x =available_geo .x ()+(available_geo .width ()-widget_geo .width ())//2
- y =available_geo .y ()+(available_geo .height ()-widget_geo .height ())//2
- self .move (x ,y )
- except Exception as e :
- print (f"[Tour] Error centering dialog: {e }")
-
-
- def _init_ui (self ):
- main_layout =QVBoxLayout (self )
- main_layout .setContentsMargins (0 ,0 ,0 ,0 )
- main_layout .setSpacing (0 )
-
- self .stacked_widget =QStackedWidget ()
- main_layout .addWidget (self .stacked_widget ,1 )
- step1_content =(
- "Hello! This quick tour will walk you through the main features of the Kemono Downloader, including recent updates like enhanced filtering, manga mode improvements, and cookie management."
- )
- self .step1 =TourStepWidget (self ._tr ("tour_dialog_step1_title"),self ._tr ("tour_dialog_step1_content",step1_content ))
-
- step2_content =(
- "Let's start with the basics for downloading:"
- )
- self .step2 =TourStepWidget (self ._tr ("tour_dialog_step2_title"),self ._tr ("tour_dialog_step2_content",step2_content ))
-
- step3_content =(
- "Refine what you download with these filters (most are disabled in 'Only Links' or 'Only Archives' modes):"
- )
- self .step3_filtering =TourStepWidget (self ._tr ("tour_dialog_step3_title"),self ._tr ("tour_dialog_step3_content",step3_content ))
-
- step_favorite_mode_content =(
- "The application offers a 'Favorite Mode' for downloading content from artists you've favorited on Kemono.su."
- )
- self .step_favorite_mode =TourStepWidget (self ._tr ("tour_dialog_step4_title"),self ._tr ("tour_dialog_step4_content",step_favorite_mode_content ))
-
- step4_content =(
- "More options to customize your downloads:"
- )
- self .step4_fine_tuning =TourStepWidget (self ._tr ("tour_dialog_step5_title"),self ._tr ("tour_dialog_step5_content",step4_content ))
-
- step5_content =(
- "Organize your downloads and manage performance:"
- )
- self .step5_organization =TourStepWidget (self ._tr ("tour_dialog_step6_title"),self ._tr ("tour_dialog_step6_content",step5_content ))
-
- step6_errors_content =(
- "Sometimes, downloads might encounter issues. Here are a few common ones:"
- )
- self .step6_errors =TourStepWidget (self ._tr ("tour_dialog_step7_title"),self ._tr ("tour_dialog_step7_content",step6_errors_content ))
-
- step7_final_controls_content =(
- "Monitoring and Controls:"
- )
- self .step7_final_controls =TourStepWidget (self ._tr ("tour_dialog_step8_title"),self ._tr ("tour_dialog_step8_content",step7_final_controls_content ))
-
-
- self .tour_steps =[
- self .step1 ,
- self .step2 ,
- self .step3_filtering ,
- self .step_favorite_mode ,
- self .step4_fine_tuning ,
- self .step5_organization ,
- self .step6_errors ,self .step7_final_controls ]
- for step_widget in self .tour_steps :
- self .stacked_widget .addWidget (step_widget )
-
- self .setWindowTitle (self ._tr ("tour_dialog_title","Welcome to Kemono Downloader!"))
-
- bottom_controls_layout =QVBoxLayout ()
- bottom_controls_layout .setContentsMargins (15 ,10 ,15 ,15 )
- bottom_controls_layout .setSpacing (12 )
-
- self .never_show_again_checkbox =QCheckBox (self ._tr ("tour_dialog_never_show_checkbox","Never show this tour again"))
- bottom_controls_layout .addWidget (self .never_show_again_checkbox ,0 ,Qt .AlignLeft )
-
- buttons_layout =QHBoxLayout ()
- buttons_layout .setSpacing (10 )
-
- self .skip_button =QPushButton (self ._tr ("tour_dialog_skip_button","Skip Tour"))
- self .skip_button .clicked .connect (self ._skip_tour_action )
-
- self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
- self .back_button .clicked .connect (self ._previous_step )
- self .back_button .setEnabled (False )
-
- self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
- self .next_button .clicked .connect (self ._next_step_action )
- self .next_button .setDefault (True )
-
- buttons_layout .addWidget (self .skip_button )
- buttons_layout .addStretch (1 )
- buttons_layout .addWidget (self .back_button )
- buttons_layout .addWidget (self .next_button )
-
- bottom_controls_layout .addLayout (buttons_layout )
- main_layout .addLayout (bottom_controls_layout )
-
- self ._update_button_states ()
-
- def _handle_exit_actions (self ):
- pass
-
- def _next_step_action (self ):
- if self .current_step 0 :
- self .current_step -=1
- self .stacked_widget .setCurrentIndex (self .current_step )
- self ._update_button_states ()
-
- def _update_button_states (self ):
- if self .current_step ==len (self .tour_steps )-1 :
- self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
- else :
- self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
- self .back_button .setEnabled (self .current_step >0 )
-
- def _skip_tour_action (self ):
- self ._save_settings_if_checked ()
- self .tour_skipped .emit ()
- self .reject ()
-
- def _finish_tour_action (self ):
- self ._save_settings_if_checked ()
- self .tour_finished_normally .emit ()
- self .accept ()
-
- def _save_settings_if_checked (self ):
- if self .never_show_again_checkbox .isChecked ():
- self .settings .setValue (self .TOUR_SHOWN_KEY ,True )
- else :
- self .settings .setValue (self .TOUR_SHOWN_KEY ,False )
- self .settings .sync ()
-
- @staticmethod
- def should_show_tour (parent_app_settings =None ):
- settings_to_use =QSettings (TourDialog .CONFIG_ORGANIZATION_NAME ,TourDialog .CONFIG_APP_NAME_TOUR )
-
- never_show =settings_to_use .value (TourDialog .TOUR_SHOWN_KEY ,False ,type =bool )
- return not never_show
-
- def closeEvent (self ,event ):
- self ._skip_tour_action ()
- super ().closeEvent (event )
-
- @staticmethod
- def run_tour_if_needed (parent_app_window ):
- try :
- settings =QSettings (TourDialog .CONFIG_ORGANIZATION_NAME ,TourDialog .CONFIG_APP_NAME_TOUR )
- never_show_again_from_settings =settings .value (TourDialog .TOUR_SHOWN_KEY ,False ,type =bool )
-
- primary_screen =QApplication .primaryScreen ()
- if not primary_screen :
- screens =QApplication .screens ()
- primary_screen =screens [0 ]if screens else None
-
- dialog_width ,dialog_height =600 ,620
-
- if primary_screen :
- available_geo =primary_screen .availableGeometry ()
- screen_w ,screen_h =available_geo .width (),available_geo .height ()
- pref_w =int (screen_w *0.50 )
- pref_h =int (screen_h *0.60 )
- min_w ,max_w =550 ,700
- min_h ,max_h =580 ,750
-
- dialog_width =max (min_w ,min (pref_w ,max_w ))
- dialog_height =max (min_h ,min (pref_h ,max_h ))
-
- if never_show_again_from_settings :
- print (f"[Tour] Skipped: '{TourDialog .TOUR_SHOWN_KEY }' is True in settings.")
- return QDialog .Rejected
-
- tour_dialog =TourDialog (parent_app_window )
- tour_dialog .setFixedSize (dialog_width ,dialog_height )
- result =tour_dialog .exec_ ()
- return result
-
- except Exception as e :
- print (f"[Tour] CRITICAL ERROR in run_tour_if_needed: {e }")
- return QDialog .Rejected
-
-class ExternalLinkDownloadThread (QThread ):
- """A QThread to handle downloading multiple external links sequentially."""
- progress_signal =pyqtSignal (str )
- file_complete_signal =pyqtSignal (str ,bool )
- finished_signal =pyqtSignal ()
-
- def __init__ (self ,tasks_to_download ,download_base_path ,parent_logger_func ,parent =None ):
- super ().__init__ (parent )
- self .tasks =tasks_to_download
- self .download_base_path =download_base_path
- self .parent_logger_func =parent_logger_func
- self .is_cancelled =False
-
- def run (self ):
- self .progress_signal .emit (f"ℹ️ Starting external link download thread for {len (self .tasks )} link(s).")
- for i ,task_info in enumerate (self .tasks ):
- if self .is_cancelled :
- self .progress_signal .emit ("External link download cancelled by user.")
- break
-
- platform =task_info .get ('platform','unknown').lower ()
- full_mega_url =task_info ['url']
- post_title =task_info ['title']
- key =task_info .get ('key','')
-
- self .progress_signal .emit (f"Download ({i +1 }/{len (self .tasks )}): Starting '{post_title }' ({platform .upper ()}) from {full_mega_url }")
-
- try :
- if platform =='mega':
-
- if key :
- parsed_original_url =urlparse (full_mega_url )
- if key not in parsed_original_url .fragment :
- base_url_no_fragment =full_mega_url .split ('#')[0 ]
- full_mega_url_with_key =f"{base_url_no_fragment }#{key }"
- self .progress_signal .emit (f" Adjusted Mega URL with key: {full_mega_url_with_key }")
- else :
- full_mega_url_with_key =full_mega_url
- else :
- full_mega_url_with_key =full_mega_url
- drive_download_mega_file (full_mega_url_with_key ,self .download_base_path ,logger_func =self .parent_logger_func )
- elif platform =='google drive':
- download_gdrive_file (full_mega_url ,self .download_base_path ,logger_func =self .parent_logger_func )
- elif platform =='dropbox':
- download_dropbox_file (full_mega_url ,self .download_base_path ,logger_func =self .parent_logger_func )
- else :
- self .progress_signal .emit (f"⚠️ Unsupported platform '{platform }' for link: {full_mega_url }")
- self .file_complete_signal .emit (full_mega_url ,False )
- continue
- self .file_complete_signal .emit (full_mega_url ,True )
- except Exception as e :
- self .progress_signal .emit (f"❌ Error downloading ({platform .upper ()}) link '{full_mega_url }' (from post '{post_title }'): {e }")
- self .file_complete_signal .emit (full_mega_url ,False )
- self .finished_signal .emit ()
-
- def cancel (self ):
- self .is_cancelled =True
-
-class DynamicFilterHolder :
- def __init__ (self ,initial_filters =None ):
- self .lock =threading .Lock ()
- self ._filters =initial_filters if initial_filters is not None else []
-
- def get_filters (self ):
- with self .lock :
- return [dict (f )for f in self ._filters ]
-
- def set_filters (self ,new_filters ):
- with self .lock :
- self ._filters =[dict (f )for f in (new_filters if new_filters else [])]
-
-class DownloaderApp (QWidget ):
- character_prompt_response_signal =pyqtSignal (bool )
- log_signal =pyqtSignal (str )
- add_character_prompt_signal =pyqtSignal (str )
- overall_progress_signal =pyqtSignal (int ,int )
- file_successfully_downloaded_signal =pyqtSignal (dict )
- post_processed_for_history_signal =pyqtSignal (dict )
- finished_signal =pyqtSignal (int ,int ,bool ,list )
- external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
- file_progress_signal =pyqtSignal (str ,object )
-
-
- def __init__ (self ):
- super ().__init__ ()
- self .settings =QSettings (CONFIG_ORGANIZATION_NAME ,CONFIG_APP_NAME_MAIN )
- if getattr (sys ,'frozen',False ):
- self .app_base_dir =os .path .dirname (sys .executable )
- else :
- self .app_base_dir =os .path .dirname (os .path .abspath (__file__ ))
- self .config_file =os .path .join (self .app_base_dir ,"Known.txt")
-
- self .download_thread =None
- self .thread_pool =None
- self .cancellation_event =threading .Event ()
- self.session_file_path = os.path.join(self.app_base_dir, "session.json")
- self.session_lock = threading.Lock()
- self.interrupted_session_data = None
- self.is_restore_pending = False
- self .external_link_download_thread =None
- self .pause_event =threading .Event ()
- self .active_futures =[]
- self .total_posts_to_process =0
- self .dynamic_character_filter_holder =DynamicFilterHolder ()
- self .processed_posts_count =0
- self .creator_name_cache ={}
- self .log_signal .emit (f"ℹ️ App base directory: {self .app_base_dir }")
-
-
- self.persistent_history_file = os.path.join(self.app_base_dir, "download_history.json")
- self .last_downloaded_files_details =deque (maxlen =3 )
- self .download_history_candidates =deque (maxlen =8 )
- self .log_signal .emit (f"ℹ️ Persistent history file path set to: {self .persistent_history_file }")
- self .final_download_history_entries =[]
- self .favorite_download_queue =deque ()
- self .is_processing_favorites_queue =False
- self .download_counter =0
- self .favorite_download_queue =deque ()
- self .permanently_failed_files_for_dialog =[]
- self .last_link_input_text_for_queue_sync =""
- self .is_fetcher_thread_running =False
- self ._restart_pending =False
- self .is_processing_favorites_queue =False
- self .download_history_log =deque (maxlen =50 )
- self .skip_counter =0
- self .all_kept_original_filenames =[]
- self .cancellation_message_logged_this_session =False
- self .favorite_scope_toggle_button =None
- self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
-
- self .manga_mode_checkbox =None
-
- self .selected_cookie_filepath =None
- self .retryable_failed_files_info =[]
-
- self .is_paused =False
- self .worker_to_gui_queue =queue .Queue ()
- self .gui_update_timer =QTimer (self )
- self .actual_gui_signals =PostProcessorSignals ()
-
- self .worker_signals =PostProcessorSignals ()
- self .prompt_mutex =QMutex ()
- self ._add_character_response =None
-
- self ._original_scan_content_tooltip =("If checked, the downloader will scan the HTML content of posts for image URLs (from
tags or direct links).\n"
- "now This includes resolving relative paths from
tags to full URLs.\n"
- "Relative paths in
tags (e.g., /data/image.jpg) will be resolved to full URLs.\n"
- "Useful for cases where images are in the post description but not in the API's file/attachment list.")
-
- 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 .manga_rename_toggle_button =None
- self .favorite_mode_checkbox =None
- self .url_or_placeholder_stack =None
- self .url_input_widget =None
- self .url_placeholder_widget =None
- self .favorite_action_buttons_widget =None
- self .favorite_mode_artists_button =None
- self .favorite_mode_posts_button =None
- self .standard_action_buttons_widget =None
- self .bottom_action_buttons_stack =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 .log_verbosity_toggle_button =None
-
- self .missed_character_log_output =None
- self .log_view_stack =None
- self .current_log_view ='progress'
-
- self .link_search_input =None
- self .link_search_button =None
- self .export_links_button =None
- self .radio_only_links =None
- self .radio_only_archives =None
- self .missed_title_key_terms_count ={}
- self .missed_title_key_terms_examples ={}
- self .logged_summary_for_key_term =set ()
- self .STOP_WORDS =set (["a","an","the","is","was","were","of","for","with","in","on","at","by","to","and","or","but","i","you","he","she","it","we","they","my","your","his","her","its","our","their","com","net","org","www"])
- self .already_logged_bold_key_terms =set ()
- self .missed_key_terms_buffer =[]
- self .char_filter_scope_toggle_button =None
- self .skip_words_scope =SKIP_SCOPE_POSTS
- self .char_filter_scope =CHAR_SCOPE_TITLE
- self .manga_filename_style =self .settings .value (MANGA_FILENAME_STYLE_KEY ,STYLE_POST_TITLE ,type =str )
- self .current_theme =self .settings .value (THEME_KEY ,"dark",type =str )
- self .only_links_log_display_mode =LOG_DISPLAY_LINKS
- self .mega_download_log_preserved_once =False
- self .allow_multipart_download_setting =False
- self .use_cookie_setting =False
- self .scan_content_images_setting =self .settings .value (SCAN_CONTENT_IMAGES_KEY ,False ,type =bool )
- self .cookie_text_setting =""
- self .current_selected_language =self .settings .value (LANGUAGE_KEY ,"en",type =str )
-
- print (f"ℹ️ Known.txt will be loaded/saved at: {self .config_file }")
-
-
-
- try :
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
-
- base_dir_for_icon =sys ._MEIPASS
- else :
-
- base_dir_for_icon =os .path .dirname (os .path .abspath (__file__ ))
-
- icon_path_for_window =os .path .join (base_dir_for_icon ,'assets','Kemono.ico')
- if os .path .exists (icon_path_for_window ):
- self .setWindowIcon (QIcon (icon_path_for_window ))
- else :
- self .log_signal .emit (f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window } (tried in DownloaderApp init)")
- except Exception as e_icon_app :
- self .log_signal .emit (f"❌ Error setting main window icon in DownloaderApp init: {e_icon_app }")
-
- self .url_label_widget =None
- self .download_location_label_widget =None
-
- self .remove_from_filename_label_widget =None
- self .skip_words_label_widget =None
-
- self .setWindowTitle ("Kemono Downloader v5.5.0")
-
- self .init_ui ()
- self ._connect_signals ()
- self .log_signal .emit ("ℹ️ Local API server functionality has been removed.")
- self .log_signal .emit ("ℹ️ 'Skip Current File' button has been removed.")
- if hasattr (self ,'character_input'):
- self .character_input .setToolTip (self ._tr ("character_input_tooltip","Enter character names (comma-separated)..."))
- 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 }'")
- self .log_signal .emit (f"ℹ️ Character filter scope set to default: '{self .char_filter_scope }'")
- self .log_signal .emit (f"ℹ️ Multi-part download defaults to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}")
- self .log_signal .emit (f"ℹ️ Cookie text defaults to: Empty on launch")
- self .log_signal .emit (f"ℹ️ 'Use Cookie' setting defaults to: Disabled on launch")
- self .log_signal .emit (f"ℹ️ Scan post content for images defaults to: {'Enabled'if self .scan_content_images_setting else 'Disabled'}")
- self .log_signal .emit (f"ℹ️ Application language loaded: '{self .current_selected_language .upper ()}' (UI may not reflect this yet).")
- self ._retranslate_main_ui ()
- self ._load_persistent_history ()
- self ._load_saved_download_location ()
- self._update_button_states_and_connections() # Initial button state setup
- self._check_for_interrupted_session()
-
- def get_checkbox_map(self):
- """Returns a mapping of checkbox attribute names to their corresponding settings key."""
- return {
- 'skip_zip_checkbox': 'skip_zip',
- 'skip_rar_checkbox': 'skip_rar',
- 'download_thumbnails_checkbox': 'download_thumbnails',
- 'compress_images_checkbox': 'compress_images',
- 'use_subfolders_checkbox': 'use_subfolders',
- 'use_subfolder_per_post_checkbox': 'use_post_subfolders',
- 'use_multithreading_checkbox': 'use_multithreading',
- 'external_links_checkbox': 'show_external_links',
- 'manga_mode_checkbox': 'manga_mode_active',
- 'scan_content_images_checkbox': 'scan_content_for_images',
- 'use_cookie_checkbox': 'use_cookie',
- 'favorite_mode_checkbox': 'favorite_mode_active'
- }
-
- def _get_current_ui_settings_as_dict(self, api_url_override=None, output_dir_override=None):
- """Gathers all relevant UI settings into a JSON-serializable dictionary."""
- settings = {}
+ # Determine the base directory for logging
+ if getattr(sys, 'frozen', False):
+ base_dir_for_log = os.path.dirname(sys.executable)
+ else:
+ base_dir_for_log = os.path.dirname(os.path.abspath(__file__))
+
+ log_dir = os.path.join(base_dir_for_log, "logs")
+ log_file_path = os.path.join(log_dir, "uncaught_exceptions.log")
+
+ try:
+ os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
+ with open(log_file_path, "a", encoding="utf-8") as f:
+ f.write(f"Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
+ traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
+ f.write("-" * 80 + "\n\n")
+ except Exception as log_ex:
+ # Fallback to stderr if logging to file fails
+ print(f"CRITICAL: Failed to write to uncaught_exceptions.log: {log_ex}", file=sys.stderr)
+ traceback.print_exception(exc_type, exc_value, exc_traceback, file=sys.stderr)
+
+ # Also call the default excepthook
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
+
+
+def main():
+ """Main entry point for the Kemono Downloader application."""
+
+ # Set up global exception handling
+ sys.excepthook = handle_uncaught_exception
+
+ try:
+ # Set up application metadata for QSettings
+ QCoreApplication.setOrganizationName(CONFIG_ORGANIZATION_NAME)
+ QCoreApplication.setApplicationName(CONFIG_APP_NAME_MAIN)
- settings['api_url'] = api_url_override if api_url_override is not None else self.link_input.text().strip()
- settings['output_dir'] = output_dir_override if output_dir_override is not None else self.dir_input.text().strip()
- settings['character_filter_text'] = self.character_input.text().strip()
- settings['skip_words_text'] = self.skip_words_input.text().strip()
- settings['remove_words_text'] = self.remove_from_filename_input.text().strip()
- settings['custom_folder_name'] = self.custom_folder_input.text().strip()
- settings['cookie_text'] = self.cookie_text_input.text().strip()
- if hasattr(self, 'manga_date_prefix_input'):
- settings['manga_date_prefix'] = self.manga_date_prefix_input.text().strip()
-
- try: settings['num_threads'] = int(self.thread_count_input.text().strip())
- except (ValueError, AttributeError): settings['num_threads'] = 4
- try: settings['start_page'] = int(self.start_page_input.text().strip()) if self.start_page_input.text().strip() else None
- except (ValueError, AttributeError): settings['start_page'] = None
- try: settings['end_page'] = int(self.end_page_input.text().strip()) if self.end_page_input.text().strip() else None
- except (ValueError, AttributeError): settings['end_page'] = None
+ qt_app = QApplication(sys.argv)
- for checkbox_name, key in self.get_checkbox_map().items():
- if checkbox := getattr(self, checkbox_name, None): settings[key] = checkbox.isChecked()
+ # Create the main application window from its new module
+ downloader_app_instance = DownloaderApp()
- settings['filter_mode'] = self.get_filter_mode()
- settings['only_links'] = self.radio_only_links.isChecked()
-
- settings['skip_words_scope'] = self.skip_words_scope
- settings['char_filter_scope'] = self.char_filter_scope
- settings['manga_filename_style'] = self.manga_filename_style
- settings['allow_multipart_download'] = self.allow_multipart_download_setting
-
- return settings
-
-
- def _tr (self ,key ,default_text =""):
- """Helper to get translation based on current app language for the main window."""
- if callable (get_translation ):
- return get_translation (self .current_selected_language ,key ,default_text )
- return default_text
-
- def _load_saved_download_location (self ):
- saved_location =self .settings .value (DOWNLOAD_LOCATION_KEY ,"",type =str )
- if saved_location and os .path .isdir (saved_location ):
- if hasattr (self ,'dir_input')and self .dir_input :
- self .dir_input .setText (saved_location )
- self .log_signal .emit (f"ℹ️ Loaded saved download location: {saved_location }")
- else :
- self .log_signal .emit (f"⚠️ Found saved download location '{saved_location }', but dir_input not ready.")
- elif saved_location :
- self .log_signal .emit (f"⚠️ Found saved download location '{saved_location }', but it's not a valid directory. Ignoring.")
-
- def _check_for_interrupted_session(self):
- """Checks for an incomplete session file on startup and prepares the UI for restore if found."""
- if os.path.exists(self.session_file_path):
- try:
- with open(self.session_file_path, 'r', encoding='utf-8') as f:
- session_data = json.load(f)
-
- if "ui_settings" not in session_data or "download_state" not in session_data:
- raise ValueError("Invalid session file structure.")
-
- self.interrupted_session_data = session_data
- self.log_signal.emit("ℹ️ Incomplete download session found. UI updated for restore.")
- self._prepare_ui_for_restore()
-
- except Exception as e:
- self.log_signal.emit(f"❌ Error reading session file: {e}. Deleting corrupt session file.")
- os.remove(self.session_file_path)
- self.interrupted_session_data = None
- self.is_restore_pending = False
-
- def _prepare_ui_for_restore(self):
- """Configures the UI to a 'restore pending' state."""
- if not self.interrupted_session_data:
- return
-
- self.log_signal.emit(" UI updated for session restore.")
- settings = self.interrupted_session_data.get("ui_settings", {})
- self._load_ui_from_settings_dict(settings)
-
- self.is_restore_pending = True
- self._update_button_states_and_connections() # Update buttons for restore state, UI remains editable
-
- def _clear_session_and_reset_ui(self):
- """Clears the session file and resets the UI to its default state."""
- self._clear_session_file()
- self.interrupted_session_data = None
- self.is_restore_pending = False
- self._update_button_states_and_connections() # Ensure buttons are updated to idle state
- self.reset_application_state()
-
- def _clear_session_file(self):
- """Safely deletes the session file."""
- if os.path.exists(self.session_file_path):
- try:
- os.remove(self.session_file_path)
- self.log_signal.emit("ℹ️ Interrupted session file cleared.")
- except Exception as e:
- self.log_signal.emit(f"❌ Failed to clear session file: {e}")
-
- def _save_session_file(self, session_data):
- """Safely saves the session data to the session file using an atomic write pattern."""
- temp_session_file_path = self.session_file_path + ".tmp"
- try:
- with open(temp_session_file_path, 'w', encoding='utf-8') as f:
- json.dump(session_data, f, indent=2)
- os.replace(temp_session_file_path, self.session_file_path)
- except Exception as e:
- self.log_signal.emit(f"❌ Failed to save session state: {e}")
- if os.path.exists(temp_session_file_path):
- try:
- os.remove(temp_session_file_path)
- except Exception as e_rem:
- self.log_signal.emit(f"❌ Failed to remove temp session file: {e_rem}")
-
- 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).
- """
- # Disconnect all signals first to prevent multiple connections
- try: self.download_btn.clicked.disconnect()
- except TypeError: pass
- try: self.pause_btn.clicked.disconnect()
- except TypeError: pass
- try: self.cancel_btn.clicked.disconnect()
- except TypeError: pass
-
- is_download_active = self._is_download_active()
-
- if self.is_restore_pending:
- # State: 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)
- self.download_btn.setToolTip(self._tr("start_download_discard_tooltip", "Click to start a new download, discarding the previous session."))
-
- self.pause_btn.setText(self._tr("restore_download_button_text", "🔄 Restore Download"))
- self.pause_btn.setEnabled(True)
- self.pause_btn.clicked.connect(self.restore_download)
- self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download."))
- self.cancel_btn.setEnabled(True)
-
- self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
- self.cancel_btn.setEnabled(False) # Nothing to cancel yet
- 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)."))
- elif is_download_active:
- # State: Downloading / Paused
- 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.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)
- self.pause_btn.clicked.connect(self._handle_pause_resume_action)
- self.pause_btn.setToolTip(self._tr("resume_download_button_tooltip", "Click to resume the download.") if self.is_paused else self._tr("pause_download_button_tooltip", "Click to pause the download."))
-
- self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
- self.cancel_btn.setEnabled(True)
- self.cancel_btn.clicked.connect(self.cancel_download_button_action)
- 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)."))
+ # --- Window Sizing and Positioning ---
+ # Logic moved from the old main.py to set an appropriate initial size
+ primary_screen = QApplication.primaryScreen()
+ if not primary_screen:
+ # Fallback for systems with no primary screen detected
+ downloader_app_instance.resize(1024, 768)
else:
- # State: Idle (No download, no 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)
-
- 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.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.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
-
-
- def _retranslate_main_ui (self ):
- """Retranslates static text elements in the main UI."""
- if self .url_label_widget :
- self .url_label_widget .setText (self ._tr ("creator_post_url_label","🔗 Kemono Creator/Post URL:"))
- if self .download_location_label_widget :
- self .download_location_label_widget .setText (self ._tr ("download_location_label","📁 Download Location:"))
- if hasattr (self ,'character_label')and self .character_label :
- self .character_label .setText (self ._tr ("filter_by_character_label","🎯 Filter by Character(s) (comma-separated):"))
- if self .skip_words_label_widget :
- self .skip_words_label_widget .setText (self ._tr ("skip_with_words_label","🚫 Skip with Words (comma-separated):"))
- if self .remove_from_filename_label_widget :
- self .remove_from_filename_label_widget .setText (self ._tr ("remove_words_from_name_label","✂️ Remove Words from name:"))
- if hasattr (self ,'radio_all'):self .radio_all .setText (self ._tr ("filter_all_radio","All"))
- if hasattr (self ,'radio_images'):self .radio_images .setText (self ._tr ("filter_images_radio","Images/GIFs"))
- if hasattr (self ,'radio_videos'):self .radio_videos .setText (self ._tr ("filter_videos_radio","Videos"))
- if hasattr (self ,'radio_only_archives'):self .radio_only_archives .setText (self ._tr ("filter_archives_radio","📦 Only Archives"))
- if hasattr (self ,'radio_only_links'):self .radio_only_links .setText (self ._tr ("filter_links_radio","🔗 Only Links"))
- if hasattr (self ,'radio_only_audio'):self .radio_only_audio .setText (self ._tr ("filter_audio_radio","🎧 Only Audio"))
- if hasattr (self ,'favorite_mode_checkbox'):self .favorite_mode_checkbox .setText (self ._tr ("favorite_mode_checkbox_label","⭐ Favorite Mode"))
- if hasattr (self ,'dir_button'):self .dir_button .setText (self ._tr ("browse_button_text","Browse..."))
- self ._update_char_filter_scope_button_text ()
- self ._update_skip_scope_button_text ()
-
- if hasattr (self ,'skip_zip_checkbox'):self .skip_zip_checkbox .setText (self ._tr ("skip_zip_checkbox_label","Skip .zip"))
- if hasattr (self ,'skip_rar_checkbox'):self .skip_rar_checkbox .setText (self ._tr ("skip_rar_checkbox_label","Skip .rar"))
- if hasattr (self ,'download_thumbnails_checkbox'):self .download_thumbnails_checkbox .setText (self ._tr ("download_thumbnails_checkbox_label","Download Thumbnails Only"))
- if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setText (self ._tr ("scan_content_images_checkbox_label","Scan Content for Images"))
- if hasattr (self ,'compress_images_checkbox'):self .compress_images_checkbox .setText (self ._tr ("compress_images_checkbox_label","Compress to WebP"))
- if hasattr (self ,'use_subfolders_checkbox'):self .use_subfolders_checkbox .setText (self ._tr ("separate_folders_checkbox_label","Separate Folders by Name/Title"))
- if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .setText (self ._tr ("subfolder_per_post_checkbox_label","Subfolder per Post"))
- if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setText (self ._tr ("use_cookie_checkbox_label","Use Cookie"))
- if hasattr (self ,'use_multithreading_checkbox'):self .update_multithreading_label (self .thread_count_input .text ()if hasattr (self ,'thread_count_input')else "1")
- if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setText (self ._tr ("show_external_links_checkbox_label","Show External Links in Log"))
- if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Manga/Comic Mode"))
- if hasattr (self ,'thread_count_label'):self .thread_count_label .setText (self ._tr ("threads_label","Threads:"))
-
- if hasattr (self ,'character_input'):
- self .character_input .setToolTip (self ._tr ("character_input_tooltip","Enter character names (comma-separated)..."))
- if hasattr (self ,'download_btn'):self .download_btn .setToolTip (self ._tr ("start_download_button_tooltip","Click to start the download or link extraction process with the current settings."))
-
-
-
-
-
- current_download_is_active =self ._is_download_active ()if hasattr (self ,'_is_download_active')else False
- self .set_ui_enabled (not current_download_is_active )
-
- if hasattr (self ,'known_chars_label'):self .known_chars_label .setText (self ._tr ("known_chars_label_text","🎭 Known Shows/Characters (for Folder Names):"))
- if hasattr (self ,'open_known_txt_button'):self .open_known_txt_button .setText (self ._tr ("open_known_txt_button_text","Open Known.txt"));self .open_known_txt_button .setToolTip (self ._tr ("open_known_txt_button_tooltip","Open the 'Known.txt' file..."))
- if hasattr (self ,'add_char_button'):self .add_char_button .setText (self ._tr ("add_char_button_text","➕ Add"));self .add_char_button .setToolTip (self ._tr ("add_char_button_tooltip","Add the name from the input field..."))
- if hasattr (self ,'add_to_filter_button'):self .add_to_filter_button .setText (self ._tr ("add_to_filter_button_text","⤵️ Add to Filter"));self .add_to_filter_button .setToolTip (self ._tr ("add_to_filter_button_tooltip","Select names from 'Known Shows/Characters' list..."))
- if hasattr (self ,'character_list'):
- self .character_list .setToolTip (self ._tr ("known_chars_list_tooltip","This list contains names used for automatic folder creation..."))
- if hasattr (self ,'delete_char_button'):self .delete_char_button .setText (self ._tr ("delete_char_button_text","🗑️ Delete Selected"));self .delete_char_button .setToolTip (self ._tr ("delete_char_button_tooltip","Delete the selected name(s)..."))
-
- if hasattr (self ,'cancel_btn'):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)."))
- if hasattr (self ,'error_btn'):self .error_btn .setText (self ._tr ("error_button_text","Error"));self .error_btn .setToolTip (self ._tr ("error_button_tooltip","View files skipped due to errors and optionally retry them."))
- if hasattr (self ,'progress_log_label'):self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
- if hasattr (self ,'reset_button'):self .reset_button .setText (self ._tr ("reset_button_text","🔄 Reset"));self .reset_button .setToolTip (self ._tr ("reset_button_tooltip","Reset all inputs and logs to default state (only when idle)."))
- self ._update_multipart_toggle_button_text ()
- if hasattr (self ,'progress_label')and not self ._is_download_active ():self .progress_label .setText (self ._tr ("progress_idle_text","Progress: Idle"))
- if hasattr (self ,'favorite_mode_artists_button'):self .favorite_mode_artists_button .setText (self ._tr ("favorite_artists_button_text","🖼️ Favorite Artists"));self .favorite_mode_artists_button .setToolTip (self ._tr ("favorite_artists_button_tooltip","Browse and download from your favorite artists..."))
- if hasattr (self ,'favorite_mode_posts_button'):self .favorite_mode_posts_button .setText (self ._tr ("favorite_posts_button_text","📄 Favorite Posts"));self .favorite_mode_posts_button .setToolTip (self ._tr ("favorite_posts_button_tooltip","Browse and download your favorite posts..."))
- self ._update_favorite_scope_button_text ()
- if hasattr (self ,'page_range_label'):self .page_range_label .setText (self ._tr ("page_range_label_text","Page Range:"))
- if hasattr (self ,'start_page_input'):
- self .start_page_input .setPlaceholderText (self ._tr ("start_page_input_placeholder","Start"))
- self .start_page_input .setToolTip (self ._tr ("start_page_input_tooltip","For creator URLs: Specify the starting page number..."))
- if hasattr (self ,'to_label'):self .to_label .setText (self ._tr ("page_range_to_label_text","to"))
- if hasattr (self ,'end_page_input'):
- self .end_page_input .setPlaceholderText (self ._tr ("end_page_input_placeholder","End"))
- self .end_page_input .setToolTip (self ._tr ("end_page_input_tooltip","For creator URLs: Specify the ending page number..."))
- if hasattr (self ,'fav_mode_active_label'):
- self .fav_mode_active_label .setText (self ._tr ("fav_mode_active_label_text","⭐ Favorite Mode is active..."))
- if hasattr (self ,'cookie_browse_button'):
- self .cookie_browse_button .setToolTip (self ._tr ("cookie_browse_button_tooltip","Browse for a cookie file..."))
- self ._update_manga_filename_style_button_text ()
- if hasattr (self ,'export_links_button'):self .export_links_button .setText (self ._tr ("export_links_button_text","Export Links"))
- if hasattr (self ,'download_extracted_links_button'):self .download_extracted_links_button .setText (self ._tr ("download_extracted_links_button_text","Download"))
- self ._update_log_display_mode_button_text ()
-
-
- if hasattr (self ,'radio_all'):self .radio_all .setToolTip (self ._tr ("radio_all_tooltip","Download all file types found in posts."))
- if hasattr (self ,'radio_images'):self .radio_images .setToolTip (self ._tr ("radio_images_tooltip","Download only common image formats (JPG, PNG, GIF, WEBP, etc.)."))
- if hasattr (self ,'radio_videos'):self .radio_videos .setToolTip (self ._tr ("radio_videos_tooltip","Download only common video formats (MP4, MKV, WEBM, MOV, etc.)."))
- if hasattr (self ,'radio_only_archives'):self .radio_only_archives .setToolTip (self ._tr ("radio_only_archives_tooltip","Exclusively download .zip and .rar files. Other file-specific options are disabled."))
- if hasattr (self ,'radio_only_audio'):self .radio_only_audio .setToolTip (self ._tr ("radio_only_audio_tooltip","Download only common audio formats (MP3, WAV, FLAC, etc.)."))
- if hasattr (self ,'radio_only_links'):self .radio_only_links .setToolTip (self ._tr ("radio_only_links_tooltip","Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled."))
-
-
- if hasattr (self ,'use_subfolders_checkbox'):self .use_subfolders_checkbox .setToolTip (self ._tr ("use_subfolders_checkbox_tooltip","Create subfolders based on 'Filter by Character(s)' input..."))
- if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .setToolTip (self ._tr ("use_subfolder_per_post_checkbox_tooltip","Creates a subfolder for each post..."))
- if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setToolTip (self ._tr ("use_cookie_checkbox_tooltip","If checked, will attempt to use cookies..."))
- if hasattr (self ,'use_multithreading_checkbox'):self .use_multithreading_checkbox .setToolTip (self ._tr ("use_multithreading_checkbox_tooltip","Enables concurrent operations..."))
- if hasattr (self ,'thread_count_input'):self .thread_count_input .setToolTip (self ._tr ("thread_count_input_tooltip","Number of concurrent operations..."))
- if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setToolTip (self ._tr ("external_links_checkbox_tooltip","If checked, a secondary log panel appears..."))
- if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setToolTip (self ._tr ("manga_mode_checkbox_tooltip","Downloads posts from oldest to newest..."))
-
- if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setToolTip (self ._tr ("scan_content_images_checkbox_tooltip",self ._original_scan_content_tooltip ))
- if hasattr (self ,'download_thumbnails_checkbox'):self .download_thumbnails_checkbox .setToolTip (self ._tr ("download_thumbnails_checkbox_tooltip","Downloads small preview images..."))
- if hasattr (self ,'skip_words_input'):
- self .skip_words_input .setToolTip (self ._tr ("skip_words_input_tooltip",
- ("Enter words, comma-separated, to skip downloading certain content (e.g., WIP, sketch, preview).\n\n"
- "The 'Scope: [Type]' button next to this input cycles how this filter applies:\n"
- "- Scope: Files: Skips individual files if their names contain any of these words.\n"
- "- Scope: Posts: Skips entire posts if their titles contain any of these words.\n"
- "- Scope: Both: Applies both (post title first, then individual files if post title is okay).")))
- if hasattr (self ,'remove_from_filename_input'):
- self .remove_from_filename_input .setToolTip (self ._tr ("remove_words_input_tooltip",
- ("Enter words, comma-separated, to remove from downloaded filenames (case-insensitive).\n"
- "Useful for cleaning up common prefixes/suffixes.\nExample: patreon, kemono, [HD], _final")))
-
- if hasattr (self ,'link_input'):
- self .link_input .setPlaceholderText (self ._tr ("link_input_placeholder_text","e.g., https://kemono.su/patreon/user/12345 or .../post/98765"))
- self .link_input .setToolTip (self ._tr ("link_input_tooltip_text","Enter the full URL..."))
- if hasattr (self ,'dir_input'):
- self .dir_input .setPlaceholderText (self ._tr ("dir_input_placeholder_text","Select folder where downloads will be saved"))
- self .dir_input .setToolTip (self ._tr ("dir_input_tooltip_text","Enter or browse to the main folder..."))
- if hasattr (self ,'character_input'):
- self .character_input .setPlaceholderText (self ._tr ("character_input_placeholder_text","e.g., Tifa, Aerith, (Cloud, Zack)"))
- if hasattr (self ,'custom_folder_input'):
- self .custom_folder_input .setPlaceholderText (self ._tr ("custom_folder_input_placeholder_text","Optional: Save this post to specific folder"))
- self .custom_folder_input .setToolTip (self ._tr ("custom_folder_input_tooltip_text","If downloading a single post URL..."))
- if hasattr (self ,'skip_words_input'):
- self .skip_words_input .setPlaceholderText (self ._tr ("skip_words_input_placeholder_text","e.g., WM, WIP, sketch, preview"))
- if hasattr (self ,'remove_from_filename_input'):
- self .remove_from_filename_input .setPlaceholderText (self ._tr ("remove_from_filename_input_placeholder_text","e.g., patreon, HD"))
- self ._update_cookie_input_placeholders_and_tooltips ()
- if hasattr (self ,'character_search_input'):
- self .character_search_input .setPlaceholderText (self ._tr ("character_search_input_placeholder_text","Search characters..."))
- self .character_search_input .setToolTip (self ._tr ("character_search_input_tooltip_text","Type here to filter the list..."))
- if hasattr (self ,'new_char_input'):
- self .new_char_input .setPlaceholderText (self ._tr ("new_char_input_placeholder_text","Add new show/character name"))
- self .new_char_input .setToolTip (self ._tr ("new_char_input_tooltip_text","Enter a new show, game, or character name..."))
- if hasattr (self ,'link_search_input'):
- self .link_search_input .setPlaceholderText (self ._tr ("link_search_input_placeholder_text","Search Links..."))
- self .link_search_input .setToolTip (self ._tr ("link_search_input_tooltip_text","When in 'Only Links' mode..."))
- if hasattr (self ,'manga_date_prefix_input'):
- self .manga_date_prefix_input .setPlaceholderText (self ._tr ("manga_date_prefix_input_placeholder_text","Prefix for Manga Filenames"))
- self .manga_date_prefix_input .setToolTip (self ._tr ("manga_date_prefix_input_tooltip_text","Optional prefix for 'Date Based'..."))
- if hasattr (self ,'empty_popup_button'):self .empty_popup_button .setToolTip (self ._tr ("empty_popup_button_tooltip_text","Open Creator Selection..."))
- if hasattr (self ,'known_names_help_button'):self .known_names_help_button .setToolTip (self ._tr ("known_names_help_button_tooltip_text","Open the application feature guide."))
- if hasattr (self ,'future_settings_button'):self .future_settings_button .setToolTip (self ._tr ("future_settings_button_tooltip_text","Open application settings..."))
- if hasattr (self ,'link_search_button'):self .link_search_button .setToolTip (self ._tr ("link_search_button_tooltip_text","Filter displayed links"))
- def apply_theme (self ,theme_name ,initial_load =False ):
- self .current_theme =theme_name
- if not initial_load :
- self .settings .setValue (THEME_KEY ,theme_name )
- self .settings .sync ()
-
- if theme_name =="dark":
- self .setStyleSheet (self .get_dark_theme ())
- if not initial_load :
- self .log_signal .emit ("🎨 Switched to Dark Mode.")
- else :
- self .setStyleSheet ("")
- if not initial_load :
- self .log_signal .emit ("🎨 Switched to Light Mode.")
- self .update ()
-
- def _get_tooltip_for_character_input (self ):
- return (
- self ._tr ("character_input_tooltip","Default tooltip if translation fails.")
- )
- def _connect_signals (self ):
- self .actual_gui_signals .progress_signal .connect (self .handle_main_log )
- self .actual_gui_signals .file_progress_signal .connect (self .update_file_progress_display )
- self .actual_gui_signals .missed_character_post_signal .connect (self .handle_missed_character_post )
- self .actual_gui_signals .external_link_signal .connect (self .handle_external_link_signal )
- self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
- self .actual_gui_signals .file_download_status_signal .connect (lambda status :None )
-
- if hasattr (self ,'character_input'):
- self .character_input .textChanged .connect (self ._on_character_input_changed_live )
- if hasattr (self ,'use_cookie_checkbox'):
- self .use_cookie_checkbox .toggled .connect (self ._update_cookie_input_visibility )
- if hasattr (self ,'link_input'):
- self .link_input .textChanged .connect (self ._sync_queue_with_link_input )
- if hasattr (self ,'cookie_browse_button'):
- self .cookie_browse_button .clicked .connect (self ._browse_cookie_file )
- if hasattr (self ,'cookie_text_input'):
- 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 )
- self .gui_update_timer .timeout .connect (self ._process_worker_queue )
- self .gui_update_timer .start (100 )
- self .log_signal .connect (self .handle_main_log )
- 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 .post_processed_for_history_signal .connect (self ._add_to_history_candidates )
- self .finished_signal .connect (self .download_finished )
- 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_toggle_button :self .log_verbosity_toggle_button .clicked .connect (self .toggle_active_log_view )
-
- if self .link_search_button :self .link_search_button .clicked .connect (self ._filter_links_log )
- if self .link_search_input :
- self .link_search_input .returnPressed .connect (self ._filter_links_log )
- self .link_search_input .textChanged .connect (self ._filter_links_log )
- if self .export_links_button :self .export_links_button .clicked .connect (self ._export_links_to_file )
-
- if self .manga_mode_checkbox :self .manga_mode_checkbox .toggled .connect (self .update_ui_for_manga_mode )
-
-
- if hasattr (self ,'download_extracted_links_button'):
- self .download_extracted_links_button .clicked .connect (self ._show_download_extracted_links_dialog )
-
- if hasattr (self ,'log_display_mode_toggle_button'):
- self .log_display_mode_toggle_button .clicked .connect (self ._toggle_log_display_mode )
-
- if self .manga_rename_toggle_button :self .manga_rename_toggle_button .clicked .connect (self ._toggle_manga_filename_style )
-
- 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 )
-
- if self .char_filter_scope_toggle_button :
- self .char_filter_scope_toggle_button .clicked .connect (self ._cycle_char_filter_scope )
-
- if hasattr (self ,'multipart_toggle_button'):self .multipart_toggle_button .clicked .connect (self ._toggle_multipart_mode )
-
-
- if hasattr (self ,'favorite_mode_checkbox'):
- self .favorite_mode_checkbox .toggled .connect (self ._handle_favorite_mode_toggle )
-
- if hasattr (self ,'open_known_txt_button'):
- self .open_known_txt_button .clicked .connect (self ._open_known_txt_file )
-
- if hasattr (self ,'add_to_filter_button'):
- self .add_to_filter_button .clicked .connect (self ._show_add_to_filter_dialog )
- if hasattr (self ,'favorite_mode_artists_button'):
- self .favorite_mode_artists_button .clicked .connect (self ._show_favorite_artists_dialog )
- if hasattr (self ,'favorite_mode_posts_button'):
- self .favorite_mode_posts_button .clicked .connect (self ._show_favorite_posts_dialog )
- if hasattr (self ,'favorite_scope_toggle_button'):
- self .favorite_scope_toggle_button .clicked .connect (self ._cycle_favorite_scope )
- if hasattr (self ,'history_button'):
- self .history_button .clicked .connect (self ._show_download_history_dialog )
- if hasattr (self ,'error_btn'):
- self .error_btn .clicked .connect (self ._show_error_files_dialog )
-
- def _on_character_input_changed_live (self ,text ):
- """
- Called when the character input field text changes.
- If a download is active (running or paused), this updates the dynamic filter holder.
- """
- if self ._is_download_active ():
- QCoreApplication .processEvents ()
- raw_character_filters_text =self .character_input .text ().strip ()
- parsed_filters =self ._parse_character_filters (raw_character_filters_text )
-
- self .dynamic_character_filter_holder .set_filters (parsed_filters )
-
- def _parse_character_filters (self ,raw_text ):
- """Helper to parse character filter string into list of objects."""
- parsed_character_filter_objects =[]
- if raw_text :
- raw_parts =[]
- current_part_buffer =""
- in_group_parsing =False
- for char_token in raw_text :
- if char_token =='('and not in_group_parsing :
- in_group_parsing =True
- current_part_buffer +=char_token
- elif char_token ==')'and in_group_parsing :
- 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
-
- is_tilde_group =part_str .startswith ("(")and part_str .endswith (")~")
- is_standard_group_for_splitting =part_str .startswith ("(")and part_str .endswith (")")and not is_tilde_group
-
- if is_tilde_group :
- group_content_str =part_str [1 :-2 ].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 ,"is_group":True ,"aliases":aliases_in_group })
- elif is_standard_group_for_splitting :
- 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 ,
- "is_group":True ,
- "aliases":aliases_in_group ,
- "components_are_distinct_for_known_txt":True
- })
- else :
- parsed_character_filter_objects .append ({"name":part_str ,"is_group":False ,"aliases":[part_str ],"components_are_distinct_for_known_txt":False })
- return parsed_character_filter_objects
-
- def _process_worker_queue (self ):
- """Processes messages from the worker queue and emits Qt signals from the GUI thread."""
- while not self .worker_to_gui_queue .empty ():
- try :
- item =self .worker_to_gui_queue .get_nowait ()
- signal_type =item .get ('type')
- payload =item .get ('payload',tuple ())
-
- if signal_type =='progress':
- self .actual_gui_signals .progress_signal .emit (*payload )
- elif signal_type =='file_download_status':
- self .actual_gui_signals .file_download_status_signal .emit (*payload )
- elif signal_type =='external_link':
- self .actual_gui_signals .external_link_signal .emit (*payload )
- elif signal_type =='file_progress':
- self .actual_gui_signals .file_progress_signal .emit (*payload )
- elif signal_type =='missed_character_post':
- self .actual_gui_signals .missed_character_post_signal .emit (*payload )
- elif signal_type =='file_successfully_downloaded':
- self ._handle_actual_file_downloaded (payload [0 ]if payload else {})
- elif signal_type =='file_successfully_downloaded':
- self ._handle_file_successfully_downloaded (payload [0 ])
- else :
- self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }")
- self .worker_to_gui_queue .task_done ()
- except queue .Empty :
- break
- except Exception as e :
- self .log_signal .emit (f"❌ Error processing worker queue: {e }")
-
- def load_known_names_from_util (self ):
- global KNOWN_NAMES
- if os .path .exists (self .config_file ):
- parsed_known_objects =[]
- try :
- with open (self .config_file ,'r',encoding ='utf-8')as f :
- for line_num ,line in enumerate (f ,1 ):
- line =line .strip ()
- if not line :continue
-
- if line .startswith ("(")and line .endswith (")"):
- content =line [1 :-1 ].strip ()
- parts =[p .strip ()for p in content .split (',')if p .strip ()]
- if parts :
- folder_name_raw =content .replace (',',' ')
- folder_name_cleaned =clean_folder_name (folder_name_raw )
-
- unique_aliases_set ={p for p in parts }
- final_aliases_list =sorted (list (unique_aliases_set ),key =str .lower )
-
- if not folder_name_cleaned :
- if hasattr (self ,'log_signal'):self .log_signal .emit (f"⚠️ Group resulted in empty folder name after cleaning in Known.txt on line {line_num }: '{line }'. Skipping entry.")
- continue
-
- parsed_known_objects .append ({
- "name":folder_name_cleaned ,
- "is_group":True ,
- "aliases":final_aliases_list
- })
- else :
- if hasattr (self ,'log_signal'):self .log_signal .emit (f"⚠️ Empty group found in Known.txt on line {line_num }: '{line }'")
- else :
- parsed_known_objects .append ({
- "name":line ,
- "is_group":False ,
- "aliases":[line ]
- })
- parsed_known_objects .sort (key =lambda x :x ["name"].lower ())
- KNOWN_NAMES [:]=parsed_known_objects
- log_msg =f"ℹ️ Loaded {len (KNOWN_NAMES )} known entries from {self .config_file }"
- except Exception as e :
- 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 }")
- KNOWN_NAMES [:]=[]
- else :
- self .character_input .setToolTip ("Names, comma-separated. Group aliases: (alias1, alias2, alias3) becomes folder name 'alias1 alias2 alias3' (after cleaning).\nAll names in the group are used as aliases for matching.\nE.g., yor, (Boa, Hancock, Snake Princess)")
- log_msg =f"ℹ️ Config file '{self .config_file }' not found. It will be created on save."
- KNOWN_NAMES [:]=[]
-
- if hasattr (self ,'log_signal'):self .log_signal .emit (log_msg )
-
- if hasattr (self ,'character_list'):
- self .character_list .clear ()
- if not KNOWN_NAMES :
- self .log_signal .emit ("ℹ️ 'Known.txt' is empty or was not found. No default entries will be added.")
-
- self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
-
- def save_known_names (self ):
- global KNOWN_NAMES
- try :
- with open (self .config_file ,'w',encoding ='utf-8')as f :
- for entry in KNOWN_NAMES :
- if entry ["is_group"]:
- f .write (f"({', '.join (sorted (entry ['aliases'],key =str .lower ))})\n")
- else :
- f .write (entry ["name"]+'\n')
- if hasattr (self ,'log_signal'):self .log_signal .emit (f"💾 Saved {len (KNOWN_NAMES )} known entries to {self .config_file }")
- except Exception as e :
- log_msg =f"❌ Error saving config '{self .config_file }': {e }"
- if hasattr (self ,'log_signal'):self .log_signal .emit (log_msg )
- QMessageBox .warning (self ,"Config Save Error",f"Could not save list to {self .config_file }:\n{e }")
-
- def closeEvent (self ,event ):
- self .save_known_names ()
- self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
- self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting )
- self .settings .setValue (COOKIE_TEXT_KEY ,self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "")
- self .settings .setValue (SCAN_CONTENT_IMAGES_KEY ,self .scan_content_images_checkbox .isChecked ()if hasattr (self ,'scan_content_images_checkbox')else False )
- self .settings .setValue (USE_COOKIE_KEY ,self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False )
- self .settings .setValue (THEME_KEY ,self .current_theme )
- self .settings .setValue (LANGUAGE_KEY ,self .current_selected_language )
- self .settings .sync ()
- self ._save_persistent_history ()
-
- should_exit =True
- is_downloading =self ._is_download_active ()
-
- if is_downloading :
- reply =QMessageBox .question (self ,"Confirm Exit",
- "Download in progress. Are you sure you want to exit and cancel?",
- QMessageBox .Yes |QMessageBox .No ,QMessageBox .No )
- if reply ==QMessageBox .Yes :
- self .log_signal .emit ("⚠️ Cancelling active download due to application exit...")
- 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.")
- if self .download_thread and self .download_thread .isRunning ():
- self .log_signal .emit (" Waiting for single download thread to finish...")
- self .download_thread .wait (3000 )
- if self .download_thread .isRunning ():
- self .log_signal .emit (" ⚠️ Single download thread did not terminate gracefully.")
-
- if self .thread_pool :
- self .log_signal .emit (" Shutting down thread pool (waiting for completion)...")
- self .thread_pool .shutdown (wait =True ,cancel_futures =True )
- self .log_signal .emit (" Thread pool shutdown complete.")
- self .thread_pool =None
- self .log_signal .emit (" Cancellation for exit complete.")
- else :
- should_exit =False
- self .log_signal .emit ("ℹ️ Application exit cancelled.")
- event .ignore ()
- return
-
- if should_exit :
- self .log_signal .emit ("ℹ️ Application closing.")
- if self .thread_pool :
- self .log_signal .emit (" Final thread pool check: Shutting down...")
- self .cancellation_event .set ()
- self .thread_pool .shutdown (wait =True ,cancel_futures =True )
- self .thread_pool =None
- self .log_signal .emit ("👋 Exiting application.")
- event .accept ()
-
-
- def _request_restart_application (self ):
- self .log_signal .emit ("🔄 Application restart requested by user for language change.")
- self ._restart_pending =True
- self .close ()
-
- def _do_actual_restart (self ):
- try :
- self .log_signal .emit (" Performing application restart...")
- python_executable =sys .executable
- script_args =sys .argv
-
-
- if getattr (sys ,'frozen',False ):
-
-
-
- QProcess .startDetached (python_executable ,script_args [1 :])
- else :
-
-
- QProcess .startDetached (python_executable ,script_args )
-
- QCoreApplication .instance ().quit ()
- except Exception as e :
- self .log_signal .emit (f"❌ CRITICAL: Failed to start new application instance: {e }")
- QMessageBox .critical (self ,"Restart Failed",
- f"Could not automatically restart the application: {e }\n\nPlease restart it manually.")
-
-
- def init_ui (self ):
- self .main_splitter =QSplitter (Qt .Horizontal )
- 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 )
- self .apply_theme (self .current_theme ,initial_load =True )
-
- self .url_input_widget =QWidget ()
- url_input_layout =QHBoxLayout (self .url_input_widget )
- url_input_layout .setContentsMargins (0 ,0 ,0 ,0 )
-
- self .url_label_widget =QLabel ()
- url_input_layout .addWidget (self .url_label_widget )
- self .link_input =QLineEdit ()
- self .link_input .setPlaceholderText ("e.g., https://kemono.su/patreon/user/12345 or .../post/98765")
- self .link_input .textChanged .connect (self .update_custom_folder_visibility )
- url_input_layout .addWidget (self .link_input ,1 )
- self .empty_popup_button =QPushButton ("🎨")
- self .empty_popup_button .setStyleSheet ("padding: 4px 6px;")
- self .empty_popup_button .clicked .connect (self ._show_empty_popup )
- url_input_layout .addWidget (self .empty_popup_button )
-
- self .page_range_label =QLabel (self ._tr ("page_range_label_text","Page Range:"))
- self .page_range_label .setStyleSheet ("font-weight: bold; padding-left: 10px;")
- url_input_layout .addWidget (self .page_range_label )
- self .start_page_input =QLineEdit ()
- self .start_page_input .setPlaceholderText (self ._tr ("start_page_input_placeholder","Start"))
- self .start_page_input .setFixedWidth (50 )
- self .start_page_input .setValidator (QIntValidator (1 ,99999 ))
- url_input_layout .addWidget (self .start_page_input )
- self .to_label =QLabel (self ._tr ("page_range_to_label_text","to"))
- url_input_layout .addWidget (self .to_label )
- self .end_page_input =QLineEdit ()
- self .end_page_input .setPlaceholderText (self ._tr ("end_page_input_placeholder","End"))
- self .end_page_input .setFixedWidth (50 )
- self .end_page_input .setToolTip (self ._tr ("end_page_input_tooltip","For creator URLs: Specify the ending page number..."))
- self .end_page_input .setValidator (QIntValidator (1 ,99999 ))
- url_input_layout .addWidget (self .end_page_input )
-
- self .url_placeholder_widget =QWidget ()
- placeholder_layout =QHBoxLayout (self .url_placeholder_widget )
- placeholder_layout .setContentsMargins (0 ,0 ,0 ,0 )
- self .fav_mode_active_label =QLabel (self ._tr ("fav_mode_active_label_text","⭐ Favorite Mode is active..."))
- self .fav_mode_active_label .setAlignment (Qt .AlignCenter )
- placeholder_layout .addWidget (self .fav_mode_active_label )
-
- self .url_or_placeholder_stack =QStackedWidget ()
- self .url_or_placeholder_stack .addWidget (self .url_input_widget )
- self .url_or_placeholder_stack .addWidget (self .url_placeholder_widget )
- left_layout .addWidget (self .url_or_placeholder_stack )
-
- self .favorite_action_buttons_widget =QWidget ()
- favorite_buttons_layout =QHBoxLayout (self .favorite_action_buttons_widget )
- favorite_buttons_layout .setContentsMargins (0 ,0 ,0 ,0 )
- self .favorite_mode_artists_button =QPushButton ("🖼️ Favorite Artists")
- self .favorite_mode_artists_button .setToolTip ("Browse and download from your favorite artists on Kemono.su.")
- self .favorite_mode_artists_button .setStyleSheet ("padding: 4px 12px;")
- self .favorite_mode_artists_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred )
- self .favorite_mode_posts_button =QPushButton ("📄 Favorite Posts")
- self .favorite_mode_posts_button .setToolTip ("Browse and download your favorite posts from Kemono.su.")
- self .favorite_mode_posts_button .setStyleSheet ("padding: 4px 12px;")
- self .favorite_mode_posts_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred )
-
- self .favorite_scope_toggle_button =QPushButton ()
- self .favorite_scope_toggle_button .setStyleSheet ("padding: 4px 10px;")
- self .favorite_scope_toggle_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred )
-
- favorite_buttons_layout .addWidget (self .favorite_mode_artists_button )
- favorite_buttons_layout .addWidget (self .favorite_mode_posts_button )
- favorite_buttons_layout .addWidget (self .favorite_scope_toggle_button )
-
-
- self .download_location_label_widget =QLabel ()
- left_layout .addWidget (self .download_location_label_widget )
- self .dir_input =QLineEdit ()
- self .dir_input .setPlaceholderText ("Select folder where downloads will be saved")
- self .dir_button =QPushButton ("Browse...")
- self .dir_button .setStyleSheet ("padding: 4px 10px;")
- self .dir_button .clicked .connect (self .browse_directory )
- dir_layout =QHBoxLayout ()
- dir_layout .addWidget (self .dir_input ,1 )
- dir_layout .addWidget (self .dir_button )
- left_layout .addLayout (dir_layout )
-
-
- self .filters_and_custom_folder_container_widget =QWidget ()
- filters_and_custom_folder_layout =QHBoxLayout (self .filters_and_custom_folder_container_widget )
- filters_and_custom_folder_layout .setContentsMargins (0 ,5 ,0 ,0 )
- filters_and_custom_folder_layout .setSpacing (10 )
-
- self .character_filter_widget =QWidget ()
- character_filter_v_layout =QVBoxLayout (self .character_filter_widget )
- character_filter_v_layout .setContentsMargins (0 ,0 ,0 ,0 )
- character_filter_v_layout .setSpacing (2 )
-
- self .character_label =QLabel ("🎯 Filter by Character(s) (comma-separated):")
- 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 )
-
- self .character_input =QLineEdit ()
- self .character_input .setPlaceholderText ("e.g., Tifa, Aerith, (Cloud, Zack)")
- 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 .setStyleSheet ("padding: 4px 10px;")
- self .char_filter_scope_toggle_button .setMinimumWidth (100 )
- char_input_and_button_layout .addWidget (self .char_filter_scope_toggle_button ,1 )
-
- character_filter_v_layout .addLayout (char_input_and_button_layout )
-
-
- self .custom_folder_widget =QWidget ()
- custom_folder_v_layout =QVBoxLayout (self .custom_folder_widget )
- custom_folder_v_layout .setContentsMargins (0 ,0 ,0 ,0 )
- 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 )
- self .custom_folder_widget .setVisible (False )
-
- filters_and_custom_folder_layout .addWidget (self .character_filter_widget ,1 )
- filters_and_custom_folder_layout .addWidget (self .custom_folder_widget ,1 )
-
- left_layout .addWidget (self .filters_and_custom_folder_container_widget )
- word_manipulation_container_widget =QWidget ()
- word_manipulation_outer_layout =QHBoxLayout (word_manipulation_container_widget )
- word_manipulation_outer_layout .setContentsMargins (0 ,0 ,0 ,0 )
- word_manipulation_outer_layout .setSpacing (15 )
- skip_words_widget =QWidget ()
- skip_words_vertical_layout =QVBoxLayout (skip_words_widget )
- skip_words_vertical_layout .setContentsMargins (0 ,0 ,0 ,0 )
- skip_words_vertical_layout .setSpacing (2 )
-
- self .skip_words_label_widget =QLabel ()
- skip_words_vertical_layout .addWidget (self .skip_words_label_widget )
-
- skip_input_and_button_layout =QHBoxLayout ()
- skip_input_and_button_layout =QHBoxLayout ()
- skip_input_and_button_layout .setContentsMargins (0 ,0 ,0 ,0 )
- skip_input_and_button_layout .setSpacing (10 )
- self .skip_words_input =QLineEdit ()
- self .skip_words_input .setPlaceholderText ("e.g., WM, WIP, sketch, preview")
- skip_input_and_button_layout .addWidget (self .skip_words_input ,1 )
-
- self .skip_scope_toggle_button =QPushButton ()
- self ._update_skip_scope_button_text ()
- self .skip_scope_toggle_button .setStyleSheet ("padding: 4px 10px;")
- self .skip_scope_toggle_button .setMinimumWidth (100 )
- skip_input_and_button_layout .addWidget (self .skip_scope_toggle_button ,0 )
- skip_words_vertical_layout .addLayout (skip_input_and_button_layout )
- word_manipulation_outer_layout .addWidget (skip_words_widget ,7 )
- remove_words_widget =QWidget ()
- remove_words_vertical_layout =QVBoxLayout (remove_words_widget )
- remove_words_vertical_layout .setContentsMargins (0 ,0 ,0 ,0 )
- remove_words_vertical_layout .setSpacing (2 )
- self .remove_from_filename_label_widget =QLabel ()
- remove_words_vertical_layout .addWidget (self .remove_from_filename_label_widget )
- self .remove_from_filename_input =QLineEdit ()
- self .remove_from_filename_input .setPlaceholderText ("e.g., patreon, HD")
- remove_words_vertical_layout .addWidget (self .remove_from_filename_input )
- word_manipulation_outer_layout .addWidget (remove_words_widget ,3 )
-
- left_layout .addWidget (word_manipulation_container_widget )
-
-
- 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 )
- self .radio_all =QRadioButton ("All")
- self .radio_images =QRadioButton ("Images/GIFs")
- self .radio_videos =QRadioButton ("Videos")
- self .radio_only_archives =QRadioButton ("📦 Only Archives")
- self .radio_only_audio =QRadioButton ("🎧 Only Audio")
- self .radio_only_links =QRadioButton ("🔗 Only Links")
- self .radio_all .setChecked (True )
- self .radio_group .addButton (self .radio_all )
- self .radio_group .addButton (self .radio_images )
- self .radio_group .addButton (self .radio_videos )
- self .radio_group .addButton (self .radio_only_archives )
- self .radio_group .addButton (self .radio_only_audio )
- self .radio_group .addButton (self .radio_only_links )
- radio_button_layout .addWidget (self .radio_all )
- radio_button_layout .addWidget (self .radio_images )
- radio_button_layout .addWidget (self .radio_videos )
- radio_button_layout .addWidget (self .radio_only_archives )
- radio_button_layout .addWidget (self .radio_only_audio )
- file_filter_layout .addLayout (radio_button_layout )
- left_layout .addLayout (file_filter_layout )
-
- self .favorite_mode_checkbox =QCheckBox ()
- self .favorite_mode_checkbox .setChecked (False )
- radio_button_layout .addWidget (self .radio_only_links )
- radio_button_layout .addWidget (self .favorite_mode_checkbox )
- radio_button_layout .addStretch (1 )
- checkboxes_group_layout =QVBoxLayout ()
- checkboxes_group_layout .setSpacing (10 )
-
- row1_layout =QHBoxLayout ()
- row1_layout .setSpacing (10 )
- self .skip_zip_checkbox =QCheckBox ("Skip .zip")
- self .skip_zip_checkbox .setChecked (True )
- row1_layout .addWidget (self .skip_zip_checkbox )
- self .skip_rar_checkbox =QCheckBox ("Skip .rar")
- self .skip_rar_checkbox .setChecked (True )
- row1_layout .addWidget (self .skip_rar_checkbox )
- self .download_thumbnails_checkbox =QCheckBox ("Download Thumbnails Only")
- self .download_thumbnails_checkbox .setChecked (False )
- row1_layout .addWidget (self .download_thumbnails_checkbox )
-
- self .scan_content_images_checkbox =QCheckBox ("Scan Content for Images")
- self .scan_content_images_checkbox .setChecked (self .scan_content_images_setting )
- row1_layout .addWidget (self .scan_content_images_checkbox )
-
- self .compress_images_checkbox =QCheckBox ("Compress to WebP")
- self .compress_images_checkbox .setChecked (False )
- self .compress_images_checkbox .setToolTip ("Compress images > 1.5MB to WebP format (requires Pillow).")
- row1_layout .addWidget (self .compress_images_checkbox )
-
- row1_layout .addStretch (1 )
- checkboxes_group_layout .addLayout (row1_layout )
-
- advanced_settings_label =QLabel ("⚙️ Advanced Settings:")
- checkboxes_group_layout .addWidget (advanced_settings_label )
-
- advanced_row1_layout =QHBoxLayout ()
- advanced_row1_layout .setSpacing (10 )
- self .use_subfolders_checkbox =QCheckBox ("Separate Folders by Name/Title")
- self .use_subfolders_checkbox .setChecked (True )
- self .use_subfolders_checkbox .toggled .connect (self .update_ui_for_subfolders )
- advanced_row1_layout .addWidget (self .use_subfolders_checkbox )
- self .use_subfolder_per_post_checkbox =QCheckBox ("Subfolder per Post")
- self .use_subfolder_per_post_checkbox .setChecked (False )
- self .use_subfolder_per_post_checkbox .toggled .connect (self .update_ui_for_subfolders )
- advanced_row1_layout .addWidget (self .use_subfolder_per_post_checkbox )
-
- self .use_cookie_checkbox =QCheckBox ("Use Cookie")
- self .use_cookie_checkbox .setChecked (self .use_cookie_setting )
-
- self .cookie_text_input =QLineEdit ()
- self .cookie_text_input .setPlaceholderText ("if no Select cookies.txt)")
- self .cookie_text_input .setMinimumHeight (28 )
- self .cookie_text_input .setText (self .cookie_text_setting )
-
- advanced_row1_layout .addWidget (self .use_cookie_checkbox )
- advanced_row1_layout .addWidget (self .cookie_text_input ,2 )
-
- self .cookie_browse_button =QPushButton ("Browse...")
- self .cookie_browse_button .setFixedWidth (80 )
- self .cookie_browse_button .setStyleSheet ("padding: 4px 8px;")
- advanced_row1_layout .addWidget (self .cookie_browse_button )
-
- advanced_row1_layout .addStretch (1 )
- checkboxes_group_layout .addLayout (advanced_row1_layout )
-
- advanced_row2_layout =QHBoxLayout ()
- advanced_row2_layout .setSpacing (10 )
-
- multithreading_layout =QHBoxLayout ()
- multithreading_layout .setContentsMargins (0 ,0 ,0 ,0 )
- self .use_multithreading_checkbox =QCheckBox ("Use Multithreading")
- self .use_multithreading_checkbox .setChecked (True )
- multithreading_layout .addWidget (self .use_multithreading_checkbox )
- self .thread_count_label =QLabel ("Threads:")
- multithreading_layout .addWidget (self .thread_count_label )
- self .thread_count_input =QLineEdit ()
- self .thread_count_input .setFixedWidth (40 )
- self .thread_count_input .setText ("4")
- self .thread_count_input .setValidator (QIntValidator (1 ,MAX_THREADS ))
- multithreading_layout .addWidget (self .thread_count_input )
- advanced_row2_layout .addLayout (multithreading_layout )
-
- self .external_links_checkbox =QCheckBox ("Show External Links in Log")
- self .external_links_checkbox .setChecked (False )
- advanced_row2_layout .addWidget (self .external_links_checkbox )
-
- self .manga_mode_checkbox =QCheckBox ("Manga/Comic Mode")
- self .manga_mode_checkbox .setChecked (False )
-
- advanced_row2_layout .addWidget (self .manga_mode_checkbox )
-
-
- advanced_row2_layout .addStretch (1 )
- checkboxes_group_layout .addLayout (advanced_row2_layout )
- left_layout .addLayout (checkboxes_group_layout )
-
- self .standard_action_buttons_widget =QWidget ()
- btn_layout =QHBoxLayout ()
- btn_layout .setContentsMargins (0 ,0 ,0 ,0 )
- btn_layout .setSpacing (10 )
- self .download_btn =QPushButton ("⬇️ Start Download")
- self .download_btn .setStyleSheet ("padding: 4px 12px; font-weight: bold;")
- self .download_btn .clicked .connect (self .start_download )
-
- self .pause_btn =QPushButton ("⏸️ Pause Download")
- self .pause_btn .setEnabled (False )
- self .pause_btn .setStyleSheet ("padding: 4px 12px;")
- self .pause_btn .clicked .connect (self ._handle_pause_resume_action )
-
- self .cancel_btn =QPushButton ("❌ Cancel & Reset UI")
-
- self .cancel_btn .setEnabled (False )
- self .cancel_btn .setStyleSheet ("padding: 4px 12px;")
- self .cancel_btn .clicked .connect (self .cancel_download_button_action )
-
- self .error_btn =QPushButton ("Error")
- self .error_btn .setToolTip ("View error details (functionality TBD).")
- self .error_btn .setStyleSheet ("padding: 4px 8px;")
- self .error_btn .setEnabled (True )
- btn_layout .addWidget (self .download_btn )
- btn_layout .addWidget (self .pause_btn )
- btn_layout .addWidget (self .cancel_btn )
- btn_layout .addWidget (self .error_btn )
- self .standard_action_buttons_widget .setLayout (btn_layout )
-
- self .bottom_action_buttons_stack =QStackedWidget ()
- self .bottom_action_buttons_stack .addWidget (self .standard_action_buttons_widget )
- self .bottom_action_buttons_stack .addWidget (self .favorite_action_buttons_widget )
- left_layout .addWidget (self .bottom_action_buttons_stack )
- left_layout .addSpacing (10 )
-
-
- known_chars_label_layout =QHBoxLayout ()
- known_chars_label_layout .setSpacing (10 )
- self .known_chars_label =QLabel ("🎭 Known Shows/Characters (for Folder Names):")
- known_chars_label_layout .addWidget (self .known_chars_label )
- self .open_known_txt_button =QPushButton ("Open Known.txt")
- self .open_known_txt_button .setStyleSheet ("padding: 4px 8px;")
- self .open_known_txt_button .setFixedWidth (120 )
- known_chars_label_layout .addWidget (self .open_known_txt_button )
- self .character_search_input =QLineEdit ()
- self .character_search_input .setPlaceholderText ("Search characters...")
- known_chars_label_layout .addWidget (self .character_search_input ,1 )
- left_layout .addLayout (known_chars_label_layout )
-
- self .character_list =QListWidget ()
- self .character_list .setSelectionMode (QListWidget .ExtendedSelection )
- left_layout .addWidget (self .character_list ,1 )
-
- char_manage_layout =QHBoxLayout ()
- char_manage_layout .setSpacing (10 )
- self .new_char_input =QLineEdit ()
- self .new_char_input .setPlaceholderText ("Add new show/character name")
- self .new_char_input .setStyleSheet ("padding: 3px 5px;")
-
- self .add_char_button =QPushButton ("➕ Add")
- self .add_char_button .setStyleSheet ("padding: 4px 10px;")
-
- self .add_to_filter_button =QPushButton ("⤵️ Add to Filter")
- self .add_to_filter_button .setToolTip ("Select names from 'Known Shows/Characters' list to add to the 'Filter by Character(s)' field above.")
- self .add_to_filter_button .setStyleSheet ("padding: 4px 10px;")
-
- self .delete_char_button =QPushButton ("🗑️ Delete Selected")
- self .delete_char_button .setToolTip ("Delete the selected name(s) from the 'Known Shows/Characters' list.")
- self .delete_char_button .setStyleSheet ("padding: 4px 10px;")
-
- self .add_char_button .clicked .connect (self ._handle_ui_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 )
- char_manage_layout .addWidget (self .add_char_button ,0 )
-
- self .known_names_help_button =QPushButton ("?")
- self .known_names_help_button .setFixedWidth (35 )
- self .known_names_help_button .setStyleSheet ("padding: 4px 6px;")
- self .known_names_help_button .clicked .connect (self ._show_feature_guide )
-
- self .history_button =QPushButton ("📜")
- self .history_button .setFixedWidth (35 )
- self .history_button .setStyleSheet ("padding: 4px 6px;")
- self .history_button .setToolTip (self ._tr ("history_button_tooltip_text","View download history"))
-
- self .future_settings_button =QPushButton ("⚙️")
- self .future_settings_button .setFixedWidth (35 )
- self .future_settings_button .setStyleSheet ("padding: 4px 6px;")
- self .future_settings_button .clicked .connect (self ._show_future_settings_dialog )
- char_manage_layout .addWidget (self .add_to_filter_button ,1 )
- char_manage_layout .addWidget (self .delete_char_button ,1 )
- char_manage_layout .addWidget (self .known_names_help_button ,0 )
- char_manage_layout .addWidget (self .history_button ,0 )
- char_manage_layout .addWidget (self .future_settings_button ,0 )
- left_layout .addLayout (char_manage_layout )
- left_layout .addStretch (0 )
-
- log_title_layout =QHBoxLayout ()
- self .progress_log_label =QLabel ("📜 Progress Log:")
- log_title_layout .addWidget (self .progress_log_label )
- log_title_layout .addStretch (1 )
-
- self .link_search_input =QLineEdit ()
- self .link_search_input .setPlaceholderText ("Search Links...")
- self .link_search_input .setVisible (False )
-
- log_title_layout .addWidget (self .link_search_input )
- self .link_search_button =QPushButton ("🔍")
- self .link_search_button .setVisible (False )
- self .link_search_button .setFixedWidth (30 )
- self .link_search_button .setStyleSheet ("padding: 4px 4px;")
- log_title_layout .addWidget (self .link_search_button )
-
- self .manga_rename_toggle_button =QPushButton ()
- self .manga_rename_toggle_button .setVisible (False )
- self .manga_rename_toggle_button .setFixedWidth (140 )
- self .manga_rename_toggle_button .setStyleSheet ("padding: 4px 8px;")
- self ._update_manga_filename_style_button_text ()
- log_title_layout .addWidget (self .manga_rename_toggle_button )
- self .manga_date_prefix_input =QLineEdit ()
- self .manga_date_prefix_input .setPlaceholderText ("Prefix for Manga Filenames")
- self .manga_date_prefix_input .setVisible (False )
-
- log_title_layout .addWidget (self .manga_date_prefix_input )
-
- self .multipart_toggle_button =QPushButton ()
- self .multipart_toggle_button .setToolTip ("Toggle between Multi-part and Single-stream downloads for large files.")
- self .multipart_toggle_button .setFixedWidth (130 )
- self .multipart_toggle_button .setStyleSheet ("padding: 4px 8px;")
- self ._update_multipart_toggle_button_text ()
- log_title_layout .addWidget (self .multipart_toggle_button )
-
- self .EYE_ICON ="\U0001F441"
- self .CLOSED_EYE_ICON ="\U0001F648"
- self .log_verbosity_toggle_button =QPushButton (self .EYE_ICON )
- self .log_verbosity_toggle_button .setFixedWidth (45 )
- self .log_verbosity_toggle_button .setStyleSheet ("font-size: 11pt; padding: 4px 2px;")
- log_title_layout .addWidget (self .log_verbosity_toggle_button )
-
- self .reset_button =QPushButton ("🔄 Reset")
- self .reset_button .setFixedWidth (80 )
- self .reset_button .setStyleSheet ("padding: 4px 8px;")
- log_title_layout .addWidget (self .reset_button )
- right_layout .addLayout (log_title_layout )
-
- self .log_splitter =QSplitter (Qt .Vertical )
-
- self .log_view_stack =QStackedWidget ()
-
- self .main_log_output =QTextEdit ()
- self .main_log_output .setReadOnly (True )
- self .main_log_output .setLineWrapMode (QTextEdit .NoWrap )
- self .log_view_stack .addWidget (self .main_log_output )
-
- self .missed_character_log_output =QTextEdit ()
- self .missed_character_log_output .setReadOnly (True )
- self .missed_character_log_output .setLineWrapMode (QTextEdit .NoWrap )
- self .log_view_stack .addWidget (self .missed_character_log_output )
-
- self .external_log_output =QTextEdit ()
- self .external_log_output .setReadOnly (True )
- self .external_log_output .setLineWrapMode (QTextEdit .NoWrap )
- self .external_log_output .hide ()
-
- self .log_splitter .addWidget (self .log_view_stack )
- 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 )
- self .export_links_button =QPushButton (self ._tr ("export_links_button_text","Export Links"))
- self .export_links_button .setFixedWidth (100 )
- self .export_links_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;")
- self .export_links_button .setEnabled (False )
- self .export_links_button .setVisible (False )
- export_button_layout .addWidget (self .export_links_button )
-
- self .download_extracted_links_button =QPushButton (self ._tr ("download_extracted_links_button_text","Download"))
- self .download_extracted_links_button .setFixedWidth (100 )
- self .download_extracted_links_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;")
- self .download_extracted_links_button .setEnabled (False )
- self .download_extracted_links_button .setVisible (False )
- export_button_layout .addWidget (self .download_extracted_links_button )
- self .log_display_mode_toggle_button =QPushButton ()
- self .log_display_mode_toggle_button .setFixedWidth (120 )
- self .log_display_mode_toggle_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;")
- self .log_display_mode_toggle_button .setVisible (False )
- export_button_layout .addWidget (self .log_display_mode_toggle_button )
- right_layout .addLayout (export_button_layout )
-
-
- self .progress_label =QLabel ("Progress: Idle")
- self .progress_label .setStyleSheet ("padding-top: 5px; font-style: italic;")
- right_layout .addWidget (self .progress_label )
- self .file_progress_label =QLabel ("")
- self .file_progress_label .setToolTip ("Shows the progress of individual file downloads, including speed and size.")
- self .file_progress_label .setWordWrap (True )
- self .file_progress_label .setStyleSheet ("padding-top: 2px; font-style: italic; color: #A0A0A0;")
- right_layout .addWidget (self .file_progress_label )
-
-
- self .main_splitter .addWidget (left_panel_widget )
- self .main_splitter .addWidget (right_panel_widget )
-
- if self .width ()==0 or self .height ()==0 :
- initial_width =1024
- else :
- initial_width =self .width ()
- left_width =int (initial_width *0.35 )
- right_width =initial_width -left_width
- self .main_splitter .setSizes ([left_width ,right_width ])
-
- top_level_layout =QHBoxLayout (self )
- top_level_layout .setContentsMargins (0 ,0 ,0 ,0 )
- top_level_layout .addWidget (self .main_splitter )
-
- self .update_ui_for_subfolders (self .use_subfolders_checkbox .isChecked ())
- self .update_external_links_setting (self .external_links_checkbox .isChecked ())
- self .update_multithreading_label (self .thread_count_input .text ())
- self .update_page_range_enabled_state ()
- if self .manga_mode_checkbox :
- self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ())
- 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 ))
-
- self ._load_creator_name_cache_from_json ()
- self .load_known_names_from_util ()
- self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False )
- 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 ()
- self ._update_multithreading_for_date_mode ()
- if hasattr (self ,'download_thumbnails_checkbox'):
- self ._handle_thumbnail_mode_change (self .download_thumbnails_checkbox .isChecked ())
- if hasattr (self ,'favorite_mode_checkbox'):
-
- self ._handle_favorite_mode_toggle (False )
-
- def _load_persistent_history (self ):
- """Loads download history from a persistent file."""
- self .log_signal .emit (f"📜 Attempting to load history from: {self .persistent_history_file }")
- if os .path .exists (self .persistent_history_file ):
- try :
- with open (self .persistent_history_file ,'r',encoding ='utf-8')as f :
- loaded_data =json .load (f )
-
- if isinstance (loaded_data ,dict ):
- self .last_downloaded_files_details .clear ()
- self .last_downloaded_files_details .extend (loaded_data .get ("last_downloaded_files",[]))
- self .final_download_history_entries =loaded_data .get ("first_processed_posts",[])
- self .log_signal .emit (f"✅ Loaded {len (self .last_downloaded_files_details )} last downloaded files and {len (self .final_download_history_entries )} first processed posts from persistent history.")
- elif loaded_data is None and os .path .getsize (self .persistent_history_file )==0 :
- self .log_signal .emit (f"ℹ️ Persistent history file is empty. Initializing with empty history.")
- self .final_download_history_entries =[]
- self .last_downloaded_files_details .clear ()
- elif isinstance(loaded_data, list): # Handle old format where only first_processed_posts was saved
- self.log_signal.emit("⚠️ Persistent history file is in old format (only first_processed_posts). Converting to new format.")
- self.final_download_history_entries = loaded_data
- self.last_downloaded_files_details.clear()
- self._save_persistent_history() # Save in new format immediately
- else :
- self .log_signal .emit (f"⚠️ Persistent history file has incorrect format. Expected list, got {type (loaded_history )}. Ignoring.")
- self .final_download_history_entries =[]
- except json .JSONDecodeError :
- self .log_signal .emit (f"⚠️ Error decoding persistent history file. It might be corrupted. Ignoring.")
- self .final_download_history_entries =[]
- except Exception as e :
- self .log_signal .emit (f"❌ Error loading persistent history: {e }")
- self .final_download_history_entries =[]
- else :
- self .log_signal .emit (f"⚠️ Persistent history file NOT FOUND at: {self .persistent_history_file }. Starting with empty history.")
- self .final_download_history_entries =[]
- self ._save_persistent_history ()
-
- def _save_persistent_history (self ):
- """Saves download history to a persistent file."""
- self .log_signal .emit (f"📜 Attempting to save history to: {self .persistent_history_file }")
- try :
- history_dir =os .path .dirname (self .persistent_history_file )
- self .log_signal .emit (f" History directory: {history_dir }")
- if not os .path .exists (history_dir ):
- os .makedirs (history_dir ,exist_ok =True )
- self .log_signal .emit (f" Created history directory: {history_dir }")
+ available_geo = primary_screen.availableGeometry()
+ screen_width = available_geo.width()
+ screen_height = available_geo.height()
- history_data = {
- "last_downloaded_files": list(self.last_downloaded_files_details),
- "first_processed_posts": self.final_download_history_entries
- }
- with open (self .persistent_history_file ,'w',encoding ='utf-8')as f :
- json .dump (history_data ,f ,indent =2 )
- self .log_signal .emit (f"✅ Saved {len (self .final_download_history_entries )} history entries to: {self .persistent_history_file }")
- except Exception as e :
- self .log_signal .emit (f"❌ Error saving persistent history to {self .persistent_history_file }: {e }")
- def _load_creator_name_cache_from_json (self ):
- """Loads creator id-name-service mappings from creators.json into self.creator_name_cache."""
- self .log_signal .emit ("ℹ️ Attempting to load creators.json for creator name cache.")
+ # Define minimums and desired ratios
+ min_app_width, min_app_height = 960, 680
+ desired_width_ratio, desired_height_ratio = 0.80, 0.85
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
- base_path_for_creators =sys ._MEIPASS
- else :
- base_path_for_creators =self .app_base_dir
+ app_width = max(min_app_width, int(screen_width * desired_width_ratio))
+ app_height = max(min_app_height, int(screen_height * desired_height_ratio))
- creators_file_path =os .path .join (base_path_for_creators ,"creators.json")
-
- if not os .path .exists (creators_file_path ):
- self .log_signal .emit (f"⚠️ 'creators.json' not found at {creators_file_path }. Creator name cache will be empty.")
- self .creator_name_cache .clear ()
- return
-
- try :
- with open (creators_file_path ,'r',encoding ='utf-8')as f :
- loaded_data =json .load (f )
-
- creators_list =[]
- if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ):
- creators_list =loaded_data [0 ]
- elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ):
- creators_list =loaded_data
- else :
- self .log_signal .emit (f"⚠️ 'creators.json' has an unexpected format. Creator name cache may be incomplete.")
-
- for creator_data in creators_list :
- creator_id =creator_data .get ("id")
- name =creator_data .get ("name")
- service =creator_data .get ("service")
- if creator_id and name and service :
- self .creator_name_cache [(service .lower (),str (creator_id ))]=name
- self .log_signal .emit (f"✅ Successfully loaded {len (self .creator_name_cache )} creator names into cache from 'creators.json'.")
- except Exception as e :
- self .log_signal .emit (f"❌ Error loading 'creators.json' for name cache: {e }")
- self .creator_name_cache .clear ()
-
- def _show_download_history_dialog (self ):
- """Shows the dialog with the finalized download history."""
- last_3_downloaded =list (self .last_downloaded_files_details )
- first_processed =self .final_download_history_entries
-
- if not last_3_downloaded and not first_processed :
- QMessageBox .information (
- self ,
- self ._tr ("download_history_dialog_title_empty","Download History (Empty)"),
- self ._tr ("no_download_history_header","No Downloads Yet")
- )
- return
-
- dialog =DownloadHistoryDialog (last_3_downloaded ,first_processed ,self ,self )
- dialog .exec_ ()
-
- def _handle_actual_file_downloaded (self ,file_details_dict ):
- """Handles a successfully downloaded file for the 'last 3 downloaded' history."""
- if not file_details_dict :
- return
- file_details_dict ['download_timestamp']=time .time ()
- creator_key =(file_details_dict .get ('service','').lower (),str (file_details_dict .get ('user_id','')))
- file_details_dict ['creator_display_name']=self .creator_name_cache .get (creator_key ,file_details_dict .get ('folder_context_name','Unknown Creator/Series'))
- self .last_downloaded_files_details .append (file_details_dict )
-
-
- def _handle_file_successfully_downloaded (self ,history_entry_dict ):
- """Handles a successfully downloaded file for history logging."""
- if len (self .download_history_log )>=self .download_history_log .maxlen :
- self .download_history_log .popleft ()
- self .download_history_log .append (history_entry_dict )
-
-
- def _handle_actual_file_downloaded (self ,file_details_dict ):
- """Handles a successfully downloaded file for the 'last 3 downloaded' history."""
- if not file_details_dict :
- return
-
- file_details_dict ['download_timestamp']=time .time ()
-
-
- creator_key =(
- file_details_dict .get ('service','').lower (),
- str (file_details_dict .get ('user_id',''))
- )
- creator_display_name =self .creator_name_cache .get (creator_key ,file_details_dict .get ('folder_context_name','Unknown Creator'))
- file_details_dict ['creator_display_name']=creator_display_name
-
- self .last_downloaded_files_details .append (file_details_dict )
-
-
- def _handle_favorite_mode_toggle (self ,checked ):
- if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
- return
-
- self ._handle_favorite_mode_toggle (self .favorite_mode_checkbox .isChecked ())
- self ._update_favorite_scope_button_text ()
- if hasattr (self ,'link_input'):
- self .last_link_input_text_for_queue_sync =self .link_input .text ()
-
- def _update_download_extracted_links_button_state (self ):
- if hasattr (self ,'download_extracted_links_button')and self .download_extracted_links_button :
- is_only_links =self .radio_only_links and self .radio_only_links .isChecked ()
- if not is_only_links :
- self .download_extracted_links_button .setEnabled (False )
- return
-
- supported_platforms_for_button ={'mega','google drive','dropbox'}
- has_supported_links =any (
- link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
- )
- self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links )
-
- def _show_download_extracted_links_dialog (self ):
- """Shows the placeholder dialog for downloading extracted links."""
- if not (self .radio_only_links and self .radio_only_links .isChecked ()):
- self .log_signal .emit ("ℹ️ Download extracted links button clicked, but not in 'Only Links' mode.")
- return
-
- supported_platforms ={'mega','google drive','dropbox'}
- links_to_show_in_dialog =[]
- for link_data_tuple in self .extracted_links_cache :
- platform =link_data_tuple [3 ].lower ()
- if platform in supported_platforms :
- links_to_show_in_dialog .append ({
- 'title':link_data_tuple [0 ],
- 'link_text':link_data_tuple [1 ],
- 'url':link_data_tuple [2 ],
- 'platform':platform ,
- 'key':link_data_tuple [4 ]
- })
-
- if not links_to_show_in_dialog :
- QMessageBox .information (self ,"No Supported Links","No Mega, Google Drive, or Dropbox links were found in the extracted links.")
- return
-
- dialog =DownloadExtractedLinksDialog (links_to_show_in_dialog ,self ,self )
- dialog .download_requested .connect (self ._handle_extracted_links_download_request )
- dialog .exec_ ()
-
- def _handle_extracted_links_download_request (self ,selected_links_info ):
- if not selected_links_info :
- self .log_signal .emit ("ℹ️ No links selected for download from dialog.")
- return
-
-
- if self .radio_only_links and self .radio_only_links .isChecked ()and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
- self .main_log_output .clear ()
- self .log_signal .emit ("ℹ️ Displaying Mega download progress (extracted links hidden)...")
- self .mega_download_log_preserved_once =False
-
- current_main_dir =self .dir_input .text ().strip ()
- download_dir_for_mega =""
-
- if current_main_dir and os .path .isdir (current_main_dir ):
- download_dir_for_mega =current_main_dir
- self .log_signal .emit (f"ℹ️ Using existing main download location for external links: {download_dir_for_mega }")
- else :
- if not current_main_dir :
- self .log_signal .emit ("ℹ️ Main download location is empty. Prompting for download folder.")
- else :
- self .log_signal .emit (
- f"⚠️ Main download location '{current_main_dir }' is not a valid directory. Prompting for download folder.")
-
-
- suggestion_path =current_main_dir if current_main_dir else QStandardPaths .writableLocation (QStandardPaths .DownloadLocation )
-
- chosen_dir =QFileDialog .getExistingDirectory (
- self ,
- self ._tr ("select_download_folder_mega_dialog_title","Select Download Folder for External Links"),
- suggestion_path ,
- options =QFileDialog .ShowDirsOnly |QFileDialog .DontUseNativeDialog
- )
-
- if not chosen_dir :
- self .log_signal .emit ("ℹ️ External links download cancelled - no download directory selected from prompt.")
- return
- download_dir_for_mega =chosen_dir
-
-
- self .log_signal .emit (f"ℹ️ Preparing to download {len (selected_links_info )} selected external link(s) to: {download_dir_for_mega }")
- if not os .path .exists (download_dir_for_mega ):
- self .log_signal .emit (f"❌ Critical Error: Selected download directory '{download_dir_for_mega }' does not exist.")
- return
-
-
- tasks_for_thread =selected_links_info
-
- if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
- QMessageBox .warning (self ,"Busy","Another external link download is already in progress.")
- return
-
- self .external_link_download_thread =ExternalLinkDownloadThread (
- tasks_for_thread ,
- download_dir_for_mega ,
- self .log_signal .emit ,
- self
- )
- self .external_link_download_thread .finished .connect (self ._on_external_link_download_thread_finished )
-
- self .external_link_download_thread .progress_signal .connect (self .handle_main_log )
- self .external_link_download_thread .file_complete_signal .connect (self ._on_single_external_file_complete )
-
-
-
- self .set_ui_enabled (False )
-
- self .progress_label .setText (self ._tr ("progress_processing_post_text","Progress: Processing post {processed_posts}...").format (processed_posts =f"External Links (0/{len (tasks_for_thread )})"))
- self .external_link_download_thread .start ()
-
- def _on_external_link_download_thread_finished (self ):
- self .log_signal .emit ("✅ External link download thread finished.")
- self .progress_label .setText (f"{self ._tr ('status_completed','Completed')}: External link downloads. {self ._tr ('ready_for_new_task_text','Ready for new task.')}")
-
- self .mega_download_log_preserved_once =True
- self .log_signal .emit ("INTERNAL: mega_download_log_preserved_once SET to True.")
-
- if self .radio_only_links and self .radio_only_links .isChecked ():
- self .log_signal .emit (HTML_PREFIX +"
--- End of Mega Download Log ---
")
-
-
-
- self .set_ui_enabled (True )
-
-
-
- if self .mega_download_log_preserved_once :
- self .mega_download_log_preserved_once =False
- self .log_signal .emit ("INTERNAL: mega_download_log_preserved_once RESET to False.")
-
- if self .external_link_download_thread :
- self .external_link_download_thread .deleteLater ()
- self .external_link_download_thread =None
-
- def _on_single_external_file_complete (self ,url ,success ):
-
-
- pass
- def _show_future_settings_dialog (self ):
- """Shows the placeholder dialog for future settings."""
- dialog =FutureSettingsDialog (self )
- dialog =FutureSettingsDialog (self ,self )
- dialog .exec_ ()
-
- def _sync_queue_with_link_input (self ,current_text ):
- """
- Synchronizes the favorite_download_queue with the link_input text.
- Removes creators from the queue if their names are removed from the input field.
- Only affects items added via 'creator_popup_selection'.
- """
- if not self .favorite_download_queue :
- self .last_link_input_text_for_queue_sync =current_text
- return
-
- current_names_in_input ={name .strip ().lower ()for name in current_text .split (',')if name .strip ()}
-
- queue_copy =list (self .favorite_download_queue )
- removed_count =0
-
- for item in queue_copy :
- if item .get ('type')=='creator_popup_selection':
- item_name_lower =item .get ('name','').lower ()
- if item_name_lower and item_name_lower not in current_names_in_input :
- try :
- self .favorite_download_queue .remove (item )
- self .log_signal .emit (f"ℹ️ Creator '{item .get ('name')}' removed from download queue due to removal from URL input.")
- removed_count +=1
- except ValueError :
- self .log_signal .emit (f"⚠️ Tried to remove '{item .get ('name')}' from queue, but it was not found (sync).")
-
- self .last_link_input_text_for_queue_sync =current_text
-
- def _browse_cookie_file (self ):
- """Opens a file dialog to select a cookie file."""
- start_dir =QStandardPaths .writableLocation (QStandardPaths .DownloadLocation )
- if not start_dir :
- start_dir =os .path .dirname (self .config_file )
-
- filepath ,_ =QFileDialog .getOpenFileName (self ,"Select Cookie File",start_dir ,"Text files (*.txt);;All files (*)")
- if filepath :
- self .selected_cookie_filepath =filepath
- self .log_signal .emit (f"ℹ️ Selected cookie file: {filepath }")
- if hasattr (self ,'cookie_text_input'):
- self .cookie_text_input .blockSignals (True )
- self .cookie_text_input .setText (filepath )
- self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip_file_selected","Using selected cookie file: {filepath}").format (filepath =filepath ))
- self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_with_file_selected_text","Using selected cookie file (see Browse...)"))
- self .cookie_text_input .setReadOnly (True )
- self .cookie_text_input .setPlaceholderText ("")
- self .cookie_text_input .blockSignals (False )
-
- def _update_cookie_input_placeholders_and_tooltips (self ):
- if hasattr (self ,'cookie_text_input'):
- if self .selected_cookie_filepath :
- self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_with_file_selected_text","Using selected cookie file..."))
- self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip_file_selected","Using selected cookie file: {filepath}").format (filepath =self .selected_cookie_filepath ))
- else :
- self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_no_file_selected_text","Cookie string (if no cookies.txt selected)"))
- self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip","Enter your cookie string directly..."))
- self .cookie_text_input .setReadOnly (True )
- self .cookie_text_input .setPlaceholderText ("")
- self .cookie_text_input .blockSignals (False )
-
- def _center_on_screen (self ):
- """Centers the widget on the screen."""
- try :
- primary_screen =QApplication .primaryScreen ()
- if not primary_screen :
- screens =QApplication .screens ()
- if not screens :return
- primary_screen =screens [0 ]
-
- available_geo =primary_screen .availableGeometry ()
- widget_geo =self .frameGeometry ()
-
- x =available_geo .x ()+(available_geo .width ()-widget_geo .width ())//2
- y =available_geo .y ()+(available_geo .height ()-widget_geo .height ())//2
- self .move (x ,y )
- except Exception as e :
- self .log_signal .emit (f"⚠️ Error centering window: {e }")
-
- def _handle_cookie_text_manual_change (self ,text ):
- """Handles manual changes to the cookie text input, especially clearing a browsed path."""
- if not hasattr (self ,'cookie_text_input')or not hasattr (self ,'use_cookie_checkbox'):
- return
- if self .selected_cookie_filepath and not text .strip ()and self .use_cookie_checkbox .isChecked ():
- self .selected_cookie_filepath =None
- self .cookie_text_input .setReadOnly (False )
- self ._update_cookie_input_placeholders_and_tooltips ()
- self .log_signal .emit ("ℹ️ Browsed cookie file path cleared from input. Switched to manual cookie string mode.")
-
-
- def get_dark_theme (self ):
- return """
- 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;
- font-family: Consolas, Courier New, monospace; font-size: 9.5pt; }
- QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; min-height: 22px; }
- QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; }
- QPushButton:pressed { background-color: #4A4A4A; }
- QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; }
- QLabel { font-weight: bold; padding-top: 4px; padding-bottom: 2px; color: #C0C0C0; }
- QRadioButton, QCheckBox { spacing: 5px; color: #E0E0E0; padding-top: 4px; padding-bottom: 4px; }
- QRadioButton::indicator, QCheckBox::indicator { width: 14px; height: 14px; }
- QListWidget { alternate-background-color: #353535; border: 1px solid #5A5A5A; }
- QListWidget::item:selected { background-color: #007ACC; color: #FFFFFF; }
- QToolTip { background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 4px; border-radius: 3px; }
- QSplitter::handle { background-color: #5A5A5A; }
- QSplitter::handle:horizontal { width: 5px; }
- QSplitter::handle:vertical { height: 5px; }
- QFrame[frameShape="4"], QFrame[frameShape="5"] {
- border: 1px solid #4A4A4A;
- border-radius: 3px;
- }
- """
-
- def browse_directory (self ):
- initial_dir_text =self .dir_input .text ()
- start_path =""
- if initial_dir_text and os .path .isdir (initial_dir_text ):
- start_path =initial_dir_text
- else :
- home_location =QStandardPaths .writableLocation (QStandardPaths .HomeLocation )
- documents_location =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation )
- if home_location and os .path .isdir (home_location ):
- start_path =home_location
- elif documents_location and os .path .isdir (documents_location ):
- start_path =documents_location
-
- self .log_signal .emit (f"ℹ️ Opening folder dialog. Suggested start path: '{start_path }'")
-
- try :
- folder =QFileDialog .getExistingDirectory (
- self ,
- "Select Download Folder",
- start_path ,
- options =QFileDialog .DontUseNativeDialog |QFileDialog .ShowDirsOnly
- )
-
- if folder :
- self .dir_input .setText (folder )
- self .log_signal .emit (f"ℹ️ Folder selected: {folder }")
- else :
- self .log_signal .emit (f"ℹ️ Folder selection cancelled by user.")
- except RuntimeError as e :
- self .log_signal .emit (f"❌ RuntimeError opening folder dialog: {e }. This might indicate a deeper Qt or system issue.")
- QMessageBox .critical (self ,"Dialog Error",f"A runtime error occurred while trying to open the folder dialog: {e }")
- except Exception as e :
- self .log_signal .emit (f"❌ Unexpected error opening folder dialog: {e }\n{traceback .format_exc (limit =3 )}")
- QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }")
-
- def handle_main_log (self ,message ):
- is_html_message =message .startswith (HTML_PREFIX )
- display_message =message
- use_html =False
-
- if is_html_message :
- display_message =message [len (HTML_PREFIX ):]
- use_html =True
-
- try :
- safe_message =str (display_message ).replace ('\x00','[NULL]')
- if use_html :
- self .main_log_output .insertHtml (safe_message )
- else :
- self .main_log_output .append (safe_message )
-
- scrollbar =self .main_log_output .verticalScrollBar ()
- if scrollbar .value ()>=scrollbar .maximum ()-30 :
- scrollbar .setValue (scrollbar .maximum ())
- except Exception as e :
- print (f"GUI Main Log Error: {e }\nOriginal Message: {message }")
- def _extract_key_term_from_title (self ,title ):
- if not title :
- return None
- title_cleaned =re .sub (r'\[.*?\]','',title )
- title_cleaned =re .sub (r'\(.*?\)','',title_cleaned )
- title_cleaned =title_cleaned .strip ()
- word_matches =list (re .finditer (r'\b[a-zA-Z][a-zA-Z0-9_-]*\b',title_cleaned ))
-
- capitalized_candidates =[]
- for match in word_matches :
- word =match .group (0 )
- if word .istitle ()and word .lower ()not in self .STOP_WORDS and len (word )>2 :
- if not (len (word )>3 and word .isupper ()):
- capitalized_candidates .append ({'text':word ,'len':len (word ),'pos':match .start ()})
-
- if capitalized_candidates :
- capitalized_candidates .sort (key =lambda x :(x ['len'],x ['pos']),reverse =True )
- return capitalized_candidates [0 ]['text']
- non_capitalized_words_info =[]
- for match in word_matches :
- word =match .group (0 )
- if word .lower ()not in self .STOP_WORDS and len (word )>3 :
- non_capitalized_words_info .append ({'text':word ,'len':len (word ),'pos':match .start ()})
-
- if non_capitalized_words_info :
- non_capitalized_words_info .sort (key =lambda x :(x ['len'],x ['pos']),reverse =True )
- return non_capitalized_words_info [0 ]['text']
-
- return None
-
- def handle_missed_character_post (self ,post_title ,reason ):
- if self .missed_character_log_output :
- key_term =self ._extract_key_term_from_title (post_title )
-
- if key_term :
- normalized_key_term =key_term .lower ()
- if normalized_key_term not in self .already_logged_bold_key_terms :
- self .already_logged_bold_key_terms .add (normalized_key_term )
- self .missed_key_terms_buffer .append (key_term )
- self ._refresh_missed_character_log ()
- else :
- print (f"Debug (Missed Char Log): Title='{post_title }', Reason='{reason }'")
-
- def _refresh_missed_character_log (self ):
- if self .missed_character_log_output :
- self .missed_character_log_output .clear ()
- sorted_terms =sorted (self .missed_key_terms_buffer ,key =str .lower )
- separator_line ="-"*40
-
- for term in sorted_terms :
- display_term =term .capitalize ()
-
- self .missed_character_log_output .append (separator_line )
- self .missed_character_log_output .append (f'{display_term }
')
- self .missed_character_log_output .append (separator_line )
- self .missed_character_log_output .append ("")
-
- scrollbar =self .missed_character_log_output .verticalScrollBar ()
- scrollbar .setValue (0 )
-
- def _is_download_active (self ):
- single_thread_active =self .download_thread and self .download_thread .isRunning ()
- fetcher_active =hasattr (self ,'is_fetcher_thread_running')and self .is_fetcher_thread_running
- pool_has_active_tasks =self .thread_pool is not None and any (not f .done ()for f in self .active_futures if f is not None )
- retry_pool_active =hasattr (self ,'retry_thread_pool')and self .retry_thread_pool is not None and hasattr (self ,'active_retry_futures')and any (not f .done ()for f in self .active_retry_futures if f is not None )
-
-
- external_dl_thread_active =hasattr (self ,'external_link_download_thread')and self .external_link_download_thread is not None and self .external_link_download_thread .isRunning ()
-
- return single_thread_active or fetcher_active or pool_has_active_tasks or retry_pool_active or external_dl_thread_active
-
- def handle_external_link_signal (self ,post_title ,link_text ,link_url ,platform ,decryption_key ):
- link_data =(post_title ,link_text ,link_url ,platform ,decryption_key )
- self .external_link_queue .append (link_data )
- if self .radio_only_links and self .radio_only_links .isChecked ():
- self .extracted_links_cache .append (link_data )
- self ._update_download_extracted_links_button_state ()
-
- is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
- 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 ):
- self ._is_processing_external_link_queue =False
- if self .external_link_queue :
- QTimer .singleShot (0 ,self ._try_process_next_external_link )
- return
-
-
- if link_data not in self .extracted_links_cache :
- self .extracted_links_cache .append (link_data )
-
- def _try_process_next_external_link (self ):
- if self ._is_processing_external_link_queue or not self .external_link_queue :
- return
-
- is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
- 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 ):
- self ._is_processing_external_link_queue =False
- if self .external_link_queue :
- QTimer .singleShot (0 ,self ._try_process_next_external_link )
- return
-
- self ._is_processing_external_link_queue =True
- link_data =self .external_link_queue .popleft ()
-
- if is_only_links_mode :
- QTimer .singleShot (0 ,lambda data =link_data :self ._display_and_schedule_next (data ))
- elif self ._is_download_active ():
- delay_ms =random .randint (4000 ,8000 )
- QTimer .singleShot (delay_ms ,lambda data =link_data :self ._display_and_schedule_next (data ))
- else :
- QTimer .singleShot (0 ,lambda data =link_data :self ._display_and_schedule_next (data ))
-
-
- 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 ()
-
- 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 ())
- formatted_link_info =f"{display_text } - {link_url } - {platform }"
-
- if decryption_key :
- formatted_link_info +=f" (Decryption Key: {decryption_key })"
-
- if is_only_links_mode :
- if post_title !=self ._current_link_post_title :
- separator_html ="
"+"-"*45 +"
"
- if self ._current_link_post_title is not None :
- self .log_signal .emit (HTML_PREFIX +separator_html )
- title_html =f'{html .escape (post_title )}
'
- 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 :
- separator ="-"*45
- self ._append_to_external_log (formatted_link_info ,separator )
-
- self ._is_processing_external_link_queue =False
- self ._try_process_next_external_link ()
-
-
- def _append_to_external_log (self ,formatted_link_text ,separator ):
- if not (self .external_log_output and self .external_log_output .isVisible ()):
- return
-
- try :
- self .external_log_output .append (formatted_link_text )
- self .external_log_output .append ("")
-
- scrollbar =self .external_log_output .verticalScrollBar ()
- if scrollbar .value ()>=scrollbar .maximum ()-50 :
- scrollbar .setValue (scrollbar .maximum ())
- except Exception as e :
- self .log_signal .emit (f"GUI External Log Append Error: {e }\nOriginal Message: {formatted_link_text }")
- print (f"GUI External Log Error (Append): {e }\nOriginal Message: {formatted_link_text }")
-
-
- def update_file_progress_display (self ,filename ,progress_info ):
- if not filename and progress_info is None :
- self .file_progress_label .setText ("")
- return
-
- if isinstance (progress_info ,list ):
- if not progress_info :
- self .file_progress_label .setText (self ._tr ("downloading_multipart_initializing_text","File: {filename} - Initializing parts...").format (filename =filename ))
- return
-
- total_downloaded_overall =sum (cs .get ('downloaded',0 )for cs in progress_info )
- 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 =self ._tr ("downloading_multipart_text","DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts} parts @ {speed:.2f} MB/s)").format (filename =filename [:20 ],downloaded_mb =dl_mb ,total_mb =total_mb ,parts =active_chunks_count ,speed =speed_MBps )
- self .file_progress_label .setText (progress_text )
-
- elif isinstance (progress_info ,tuple )and len (progress_info )==2 :
- downloaded_bytes ,total_bytes =progress_info
-
- if not filename and total_bytes ==0 and downloaded_bytes ==0 :
- self .file_progress_label .setText ("")
- return
-
- 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 )
- if total_bytes >0 :
- tot_mb =total_bytes /(1024 *1024 )
- prog_text_base =self ._tr ("downloading_file_known_size_text","Downloading '{filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)").format (filename =disp_fn ,downloaded_mb =dl_mb ,total_mb =tot_mb )
- else :
- prog_text_base =self ._tr ("downloading_file_unknown_size_text","Downloading '{filename}' ({downloaded_mb:.1f}MB)").format (filename =disp_fn ,downloaded_mb =dl_mb )
-
- self .file_progress_label .setText (prog_text_base )
- elif filename and progress_info is None :
- self .file_progress_label .setText ("")
- elif not filename and not progress_info :
- self .file_progress_label .setText ("")
-
-
- def update_external_links_setting (self ,checked ):
- is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
- is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked ()
-
- if is_only_links_mode or is_only_archives_mode :
- if self .external_log_output :self .external_log_output .hide ()
- if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ])
- return
-
- self .show_external_links =checked
- if checked :
- if self .external_log_output :self .external_log_output .show ()
- 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 :
- self .external_log_output .clear ()
- self .external_log_output .append ("🔗 External Links Found:")
- self ._try_process_next_external_link ()
- else :
- 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 .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 )
-
-
- def _handle_filter_mode_change (self ,button ,checked ):
- if not button or not checked :
- return
-
-
- is_only_links =(button ==self .radio_only_links )
- is_only_audio =(hasattr (self ,'radio_only_audio')and self .radio_only_audio is not None and button ==self .radio_only_audio )
- is_only_archives =(hasattr (self ,'radio_only_archives')and self .radio_only_archives is not None and button ==self .radio_only_archives )
-
- if self .skip_scope_toggle_button :
- self .skip_scope_toggle_button .setVisible (not (is_only_links or is_only_archives or is_only_audio ))
- if hasattr (self ,'multipart_toggle_button')and self .multipart_toggle_button :
- self .multipart_toggle_button .setVisible (not (is_only_links or is_only_archives or is_only_audio ))
-
- 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 )
- self .export_links_button .setEnabled (is_only_links and bool (self .extracted_links_cache ))
-
- if hasattr (self ,'download_extracted_links_button')and self .download_extracted_links_button :
- self .download_extracted_links_button .setVisible (is_only_links )
- self ._update_download_extracted_links_button_state ()
-
- if self .download_btn :
- if is_only_links :
- self .download_btn .setText (self ._tr ("extract_links_button_text","🔗 Extract Links"))
- else :
- self .download_btn .setText (self ._tr ("start_download_button_text","⬇️ Start Download"))
- if not is_only_links and self .link_search_input :self .link_search_input .clear ()
-
- file_download_mode_active =not is_only_links
-
-
-
- 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 )
- if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .setEnabled (file_download_mode_active )
-
- if self .skip_zip_checkbox :
- can_skip_zip =file_download_mode_active and not is_only_archives
- self .skip_zip_checkbox .setEnabled (can_skip_zip )
- if is_only_archives :
- self .skip_zip_checkbox .setChecked (False )
-
- if self .skip_rar_checkbox :
- can_skip_rar =file_download_mode_active and not is_only_archives
- self .skip_rar_checkbox .setEnabled (can_skip_rar )
- if is_only_archives :
- self .skip_rar_checkbox .setChecked (False )
-
- other_file_proc_enabled =file_download_mode_active 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 =file_download_mode_active and not is_only_archives
- self .external_links_checkbox .setEnabled (can_show_external_log_option )
- 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 ])
-
-
- do_clear_log_in_filter_change =True
- if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
- do_clear_log_in_filter_change =False
-
- if self .main_log_output and do_clear_log_in_filter_change :
- self .log_signal .emit ("INTERNAL: _handle_filter_mode_change - About to clear log.")
- self .main_log_output .clear ()
- self .log_signal .emit ("INTERNAL: _handle_filter_mode_change - Log cleared by _handle_filter_mode_change.")
-
- if self .main_log_output :self .main_log_output .setMinimumHeight (0 )
- self .log_signal .emit ("="*20 +" Mode changed to: Only Links "+"="*20 )
- 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 )
- elif is_only_audio :
- self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")+f" ({self ._tr ('filter_audio_radio','🎧 Only Audio')})")
- 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 +f" Mode changed to: {self ._tr ('filter_audio_radio','🎧 Only Audio')} "+"="*20 )
- else :
- self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
- self .update_external_links_setting (self .external_links_checkbox .isChecked ()if self .external_links_checkbox else False )
- self .log_signal .emit (f"="*20 +f" Mode changed to: {button .text ()} "+"="*20 )
-
-
- if is_only_links :
- self ._filter_links_log ()
-
- if hasattr (self ,'log_display_mode_toggle_button'):
- self .log_display_mode_toggle_button .setVisible (is_only_links )
- self ._update_log_display_mode_button_text ()
-
- subfolders_on =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False
- manga_on =self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False
-
- character_filter_should_be_active =file_download_mode_active and not is_only_archives
-
- if self .character_filter_widget :
- self .character_filter_widget .setVisible (character_filter_should_be_active )
-
- enable_character_filter_related_widgets =character_filter_should_be_active
-
- 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 )
- self .update_custom_folder_visibility ()
- self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
-
-
- def _filter_links_log (self ):
- 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 ""
-
- 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).")
- 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.")
-
- else :
-
- 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 ="
"+"-"*45 +"
"
-
- 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'{html .escape (post_title )}
'
- 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)")
-
-
- if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ())
-
-
- 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 (*)")
-
- if filepath :
- try :
- with open (filepath ,'w',encoding ='utf-8')as f :
- current_title_for_export =None
- separator ="-"*60 +"\n"
- for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
- if post_title !=current_title_for_export :
- if current_title_for_export is not None :
- f .write ("\n"+separator +"\n")
- f .write (f"Post Title: {post_title }\n\n")
- current_title_for_export =post_title
- line_to_write =f" {link_text } - {link_url } - {platform }"
- if decryption_key :
- line_to_write +=f" (Decryption Key: {decryption_key })"
- f .write (line_to_write +"\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 }")
-
-
- def get_filter_mode (self ):
- if self .radio_only_links and self .radio_only_links .isChecked ():
- return 'all'
- elif self .radio_images .isChecked ():
- return 'image'
- elif self .radio_videos .isChecked ():
- return 'video'
- elif self .radio_only_archives and self .radio_only_archives .isChecked ():
- return 'archive'
- elif hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ():
- return 'audio'
- elif self .radio_all .isChecked ():
- return 'all'
- return 'all'
-
-
- def get_skip_words_scope (self ):
- return self .skip_words_scope
-
-
- def _update_skip_scope_button_text (self ):
- if self .skip_scope_toggle_button :
- if self .skip_words_scope ==SKIP_SCOPE_FILES :
- self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_files_text","Scope: Files"))
- self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_files_tooltip","Tooltip for skip scope files"))
- elif self .skip_words_scope ==SKIP_SCOPE_POSTS :
- self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_posts_text","Scope: Posts"))
- self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_posts_tooltip","Tooltip for skip scope posts"))
- elif self .skip_words_scope ==SKIP_SCOPE_BOTH :
- self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_both_text","Scope: Both"))
- self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_both_tooltip","Tooltip for skip scope both"))
- else :
- self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_unknown_text","Scope: Unknown"))
- self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_unknown_tooltip","Tooltip for skip scope unknown"))
-
-
- def _cycle_skip_scope (self ):
- if self .skip_words_scope ==SKIP_SCOPE_POSTS :
- self .skip_words_scope =SKIP_SCOPE_FILES
- elif self .skip_words_scope ==SKIP_SCOPE_FILES :
- self .skip_words_scope =SKIP_SCOPE_BOTH
- elif self .skip_words_scope ==SKIP_SCOPE_BOTH :
- self .skip_words_scope =SKIP_SCOPE_POSTS
- else :
- self .skip_words_scope =SKIP_SCOPE_POSTS
-
- 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 (self ._tr ("char_filter_scope_files_text","Filter: Files"))
- self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_files_tooltip","Tooltip for char filter files"))
- elif self .char_filter_scope ==CHAR_SCOPE_TITLE :
- self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_title_text","Filter: Title"))
- self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_title_tooltip","Tooltip for char filter title"))
- elif self .char_filter_scope ==CHAR_SCOPE_BOTH :
- self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_both_text","Filter: Both"))
- self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_both_tooltip","Tooltip for char filter both"))
- elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS :
- self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_comments_text","Filter: Comments (Beta)"))
- self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_comments_tooltip","Tooltip for char filter comments"))
- else :
- self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_unknown_text","Filter: Unknown"))
- self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_unknown_tooltip","Tooltip for char filter unknown"))
-
- def _cycle_char_filter_scope (self ):
- if self .char_filter_scope ==CHAR_SCOPE_TITLE :
- self .char_filter_scope =CHAR_SCOPE_FILES
- elif self .char_filter_scope ==CHAR_SCOPE_FILES :
- self .char_filter_scope =CHAR_SCOPE_BOTH
- elif self .char_filter_scope ==CHAR_SCOPE_BOTH :
- self .char_filter_scope =CHAR_SCOPE_COMMENTS
- elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS :
- self .char_filter_scope =CHAR_SCOPE_TITLE
- else :
- self .char_filter_scope =CHAR_SCOPE_TITLE
-
- 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 }'")
-
- def _handle_ui_add_new_character (self ):
- """Handles adding a new character from the UI input field."""
- name_from_ui_input =self .new_char_input .text ().strip ()
- successfully_added_any =False
-
- if not name_from_ui_input :
- QMessageBox .warning (self ,"Input Error","Name cannot be empty.")
- return
-
- if name_from_ui_input .startswith ("(")and name_from_ui_input .endswith (")~"):
- content =name_from_ui_input [1 :-2 ].strip ()
- aliases =[alias .strip ()for alias in content .split (',')if alias .strip ()]
- if aliases :
- folder_name =" ".join (aliases )
- if self .add_new_character (name_to_add =folder_name ,
- is_group_to_add =True ,
- aliases_to_add =aliases ,
- suppress_similarity_prompt =False ):
- successfully_added_any =True
- else :
- QMessageBox .warning (self ,"Input Error","Empty group content for `~` format.")
-
- elif name_from_ui_input .startswith ("(")and name_from_ui_input .endswith (")"):
- content =name_from_ui_input [1 :-1 ].strip ()
- names_to_add_separately =[name .strip ()for name in content .split (',')if name .strip ()]
- if names_to_add_separately :
- for name_item in names_to_add_separately :
- if self .add_new_character (name_to_add =name_item ,
- is_group_to_add =False ,
- aliases_to_add =[name_item ],
- suppress_similarity_prompt =False ):
- successfully_added_any =True
- else :
- QMessageBox .warning (self ,"Input Error","Empty group content for standard group format.")
- else :
- if self .add_new_character (name_to_add =name_from_ui_input ,
- is_group_to_add =False ,
- aliases_to_add =[name_from_ui_input ],
- suppress_similarity_prompt =False ):
- successfully_added_any =True
-
- if successfully_added_any :
- self .new_char_input .clear ()
- self .save_known_names ()
-
-
- def add_new_character (self ,name_to_add ,is_group_to_add ,aliases_to_add ,suppress_similarity_prompt =False ):
- global KNOWN_NAMES ,clean_folder_name
- if not name_to_add :
- QMessageBox .warning (self ,"Input Error","Name cannot be empty.");return False
-
- name_to_add_lower =name_to_add .lower ()
- for kn_entry in KNOWN_NAMES :
- if kn_entry ["name"].lower ()==name_to_add_lower :
- QMessageBox .warning (self ,"Duplicate Name",f"The primary folder name '{name_to_add }' already exists.");return False
- if not is_group_to_add and name_to_add_lower in [a .lower ()for a in kn_entry ["aliases"]]:
- QMessageBox .warning (self ,"Duplicate Alias",f"The name '{name_to_add }' already exists as an alias for '{kn_entry ['name']}'.");return False
-
- similar_names_details =[]
- for kn_entry in KNOWN_NAMES :
- for term_to_check_similarity_against in kn_entry ["aliases"]:
- term_lower =term_to_check_similarity_against .lower ()
- if name_to_add_lower !=term_lower and (name_to_add_lower in term_lower or term_lower in name_to_add_lower ):
- similar_names_details .append ((name_to_add ,kn_entry ["name"]))
- break
- for new_alias in aliases_to_add :
- if new_alias .lower ()!=term_to_check_similarity_against .lower ()and (new_alias .lower ()in term_to_check_similarity_against .lower ()or term_to_check_similarity_against .lower ()in new_alias .lower ()):
- similar_names_details .append ((new_alias ,kn_entry ["name"]))
- break
-
- if similar_names_details and not suppress_similarity_prompt :
- if similar_names_details :
- first_similar_new ,first_similar_existing =similar_names_details [0 ]
- shorter ,longer =sorted ([first_similar_new ,first_similar_existing ],key =len )
-
- msg_box =QMessageBox (self )
- msg_box .setIcon (QMessageBox .Warning )
- msg_box .setWindowTitle ("Potential Name Conflict")
- msg_box .setText (
- f"The name '{first_similar_new }' is very similar to an existing name: '{first_similar_existing }'.\n\n"
- f"This could lead to unexpected folder grouping (e.g., under '{clean_folder_name (shorter )}' instead of a more specific '{clean_folder_name (longer )}' or vice-versa).\n\n"
- "Do you want to change the name you are adding, or proceed anyway?"
- )
- change_button =msg_box .addButton ("Change Name",QMessageBox .RejectRole )
- proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
- msg_box .setDefaultButton (proceed_button )
- msg_box .setEscapeButton (change_button )
- msg_box .exec_ ()
-
- if msg_box .clickedButton ()==change_button :
- self .log_signal .emit (f"ℹ️ User chose to change '{first_similar_new }' due to similarity with an alias of '{first_similar_existing }'.")
- return False
- self .log_signal .emit (f"⚠️ User proceeded with adding '{first_similar_new }' despite similarity with an alias of '{first_similar_existing }'.")
- new_entry ={
- "name":name_to_add ,
- "is_group":is_group_to_add ,
- "aliases":sorted (list (set (aliases_to_add )),key =str .lower )
- }
- if is_group_to_add :
- for new_alias in new_entry ["aliases"]:
- if any (new_alias .lower ()==kn_entry ["name"].lower ()for kn_entry in KNOWN_NAMES if kn_entry ["name"].lower ()!=name_to_add_lower ):
- QMessageBox .warning (self ,"Alias Conflict",f"Alias '{new_alias }' (for group '{name_to_add }') conflicts with an existing primary name.");return False
- KNOWN_NAMES .append (new_entry )
- KNOWN_NAMES .sort (key =lambda x :x ["name"].lower ())
-
- self .character_list .clear ()
- self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
- self .filter_character_list (self .character_search_input .text ())
-
- log_msg_suffix =f" (as group with aliases: {', '.join (new_entry ['aliases'])})"if is_group_to_add and len (new_entry ['aliases'])>1 else ""
- self .log_signal .emit (f"✅ Added '{name_to_add }' to known names list{log_msg_suffix }.")
- self .new_char_input .clear ()
- return True
-
-
- def delete_selected_character (self ):
- global KNOWN_NAMES
- selected_items =self .character_list .selectedItems ()
- if not selected_items :
- QMessageBox .warning (self ,"Selection Error","Please select one or more names to delete.");return
-
- primary_names_to_remove ={item .text ()for item in selected_items }
- confirm =QMessageBox .question (self ,"Confirm Deletion",
- f"Are you sure you want to delete {len (primary_names_to_remove )} selected entry/entries (and their aliases)?",
- QMessageBox .Yes |QMessageBox .No ,QMessageBox .No )
- if confirm ==QMessageBox .Yes :
- original_count =len (KNOWN_NAMES )
- KNOWN_NAMES [:]=[entry for entry in KNOWN_NAMES if entry ["name"]not in primary_names_to_remove ]
- removed_count =original_count -len (KNOWN_NAMES )
-
- if removed_count >0 :
- self .log_signal .emit (f"🗑️ Removed {removed_count } name(s).")
- self .character_list .clear ()
- self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
- self .filter_character_list (self .character_search_input .text ())
- self .save_known_names ()
- else :
- self .log_signal .emit ("ℹ️ No names were removed (they might not have been in the list).")
-
-
- def update_custom_folder_visibility (self ,url_text =None ):
- if url_text is None :
- url_text =self .link_input .text ()
-
- _ ,_ ,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
-
- 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 ())or
- (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
-
- if self .custom_folder_widget :
- self .custom_folder_widget .setVisible (should_show_custom_folder )
-
- if not (self .custom_folder_widget and self .custom_folder_widget .isVisible ()):
- if self .custom_folder_input :self .custom_folder_input .clear ()
-
-
- def update_ui_for_subfolders (self ,separate_folders_by_name_title_checked :bool ):
- 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 ()
- is_only_audio =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
-
- can_enable_subfolder_per_post_checkbox =not is_only_links
-
- if self .use_subfolder_per_post_checkbox :
- self .use_subfolder_per_post_checkbox .setEnabled (can_enable_subfolder_per_post_checkbox )
-
- if not can_enable_subfolder_per_post_checkbox :
- self .use_subfolder_per_post_checkbox .setChecked (False )
-
- self .update_custom_folder_visibility ()
-
-
- def _update_cookie_input_visibility (self ,checked ):
- cookie_text_input_exists =hasattr (self ,'cookie_text_input')
- cookie_browse_button_exists =hasattr (self ,'cookie_browse_button')
-
- if cookie_text_input_exists or cookie_browse_button_exists :
- is_only_links =self .radio_only_links and self .radio_only_links .isChecked ()
- if cookie_text_input_exists :self .cookie_text_input .setVisible (checked )
- if cookie_browse_button_exists :self .cookie_browse_button .setVisible (checked )
-
- can_enable_cookie_text =checked and not is_only_links
- enable_state_for_fields =can_enable_cookie_text and (self .download_btn .isEnabled ()or self .is_paused )
-
- if cookie_text_input_exists :
- self .cookie_text_input .setEnabled (enable_state_for_fields )
- if self .selected_cookie_filepath and checked :
- self .cookie_text_input .setText (self .selected_cookie_filepath )
- self .cookie_text_input .setReadOnly (True )
- self .cookie_text_input .setPlaceholderText ("")
- elif checked :
- self .cookie_text_input .setReadOnly (False )
- self .cookie_text_input .setPlaceholderText ("Cookie string (if no cookies.txt)")
-
- if cookie_browse_button_exists :self .cookie_browse_button .setEnabled (enable_state_for_fields )
-
- if not checked :
- self .selected_cookie_filepath =None
-
-
- def update_page_range_enabled_state (self ):
- url_text =self .link_input .text ().strip ()if self .link_input else ""
- _ ,_ ,post_id =extract_post_info (url_text )
-
- is_creator_feed =not post_id if url_text else False
- enable_page_range =is_creator_feed
-
- for widget in [self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ]:
- if widget :widget .setEnabled (enable_page_range )
-
- if not enable_page_range :
- 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 ):
- if self .manga_rename_toggle_button :
- if self .manga_filename_style ==STYLE_POST_TITLE :
- 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"))
-
- 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"))
-
- elif self .manga_filename_style ==STYLE_DATE_BASED :
- self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_based_text","Name: Date Based"))
-
-
- elif self .manga_filename_style ==STYLE_DATE_POST_TITLE :
- self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_post_title_text","Name: Date + Title"))
-
- 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 ):
- current_style =self .manga_filename_style
- new_style =""
- if current_style ==STYLE_POST_TITLE :
- new_style =STYLE_ORIGINAL_NAME
- elif current_style ==STYLE_ORIGINAL_NAME :
- new_style =STYLE_DATE_POST_TITLE
- elif current_style ==STYLE_DATE_POST_TITLE :
- new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
- elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
- new_style =STYLE_DATE_BASED
- elif current_style ==STYLE_DATE_BASED :
- new_style =STYLE_POST_TITLE
- else :
- self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
- new_style =STYLE_POST_TITLE
-
- 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 .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
- self .log_signal .emit (f"ℹ️ Manga filename style changed to: '{self .manga_filename_style }'")
-
- def _handle_favorite_mode_toggle (self ,checked ):
- if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
- return
-
- self .url_or_placeholder_stack .setCurrentIndex (1 if checked else 0 )
- self .bottom_action_buttons_stack .setCurrentIndex (1 if checked else 0 )
-
- if checked :
- if self .link_input :
- self .link_input .clear ()
- self .link_input .setEnabled (False )
- for widget in [self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ]:
- if widget :widget .setEnabled (False )
- if self .start_page_input :self .start_page_input .clear ()
- if self .end_page_input :self .end_page_input .clear ()
-
- self .update_custom_folder_visibility ()
- self .update_page_range_enabled_state ()
- if self .manga_mode_checkbox :
- self .manga_mode_checkbox .setChecked (False )
- self .manga_mode_checkbox .setEnabled (False )
- if hasattr (self ,'use_cookie_checkbox'):
- self .use_cookie_checkbox .setChecked (True )
- self .use_cookie_checkbox .setEnabled (False )
- if hasattr (self ,'use_cookie_checkbox'):
- self ._update_cookie_input_visibility (True )
- self .update_ui_for_manga_mode (False )
-
- if hasattr (self ,'favorite_mode_artists_button'):
- self .favorite_mode_artists_button .setEnabled (True )
- if hasattr (self ,'favorite_mode_posts_button'):
- self .favorite_mode_posts_button .setEnabled (True )
-
- else :
- if self .link_input :self .link_input .setEnabled (True )
- self .update_page_range_enabled_state ()
- self .update_custom_folder_visibility ()
- self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
-
- if hasattr (self ,'use_cookie_checkbox'):
- self .use_cookie_checkbox .setEnabled (True )
- if hasattr (self ,'use_cookie_checkbox'):
- self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ())
-
- if hasattr (self ,'favorite_mode_artists_button'):
- self .favorite_mode_artists_button .setEnabled (False )
- if hasattr (self ,'favorite_mode_posts_button'):
- self .favorite_mode_posts_button .setEnabled (False )
-
- def update_ui_for_manga_mode (self ,checked ):
- is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
- is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked ()
- is_only_audio_mode =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
-
- url_text =self .link_input .text ().strip ()if self .link_input else ""
- _ ,_ ,post_id =extract_post_info (url_text )
-
- is_creator_feed =not post_id if url_text else False
- is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
-
- if self .manga_mode_checkbox :
- self .manga_mode_checkbox .setEnabled (is_creator_feed and not is_favorite_mode_on )
- if not is_creator_feed and self .manga_mode_checkbox .isChecked ():
- self .manga_mode_checkbox .setChecked (False )
- checked =self .manga_mode_checkbox .isChecked ()
-
- manga_mode_effectively_on =is_creator_feed and checked
-
- if self .manga_rename_toggle_button :
- self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
-
- self .update_page_range_enabled_state ()
-
- current_filename_style =self .manga_filename_style
-
- enable_char_filter_widgets =not is_only_links_mode and not is_only_archives_mode
-
- 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 )
- if self .character_filter_widget :
- self .character_filter_widget .setVisible (enable_char_filter_widgets )
-
- show_date_prefix_input =(
- manga_mode_effectively_on and
- (current_filename_style ==STYLE_DATE_BASED or
- current_filename_style ==STYLE_ORIGINAL_NAME )and
- not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )
- )
- if hasattr (self ,'manga_date_prefix_input'):
- self .manga_date_prefix_input .setVisible (show_date_prefix_input )
- if show_date_prefix_input :
- self .manga_date_prefix_input .setMaximumWidth (120 )
- self .manga_date_prefix_input .setMinimumWidth (60 )
- else :
- self .manga_date_prefix_input .clear ()
- self .manga_date_prefix_input .setMaximumWidth (16777215 )
- self .manga_date_prefix_input .setMinimumWidth (0 )
-
- if hasattr (self ,'multipart_toggle_button'):
-
- hide_multipart_button_due_mode =is_only_links_mode or is_only_archives_mode or is_only_audio_mode
- hide_multipart_button_due_manga_mode =manga_mode_effectively_on
- self .multipart_toggle_button .setVisible (not (hide_multipart_button_due_mode or hide_multipart_button_due_manga_mode ))
-
- self ._update_multithreading_for_date_mode ()
-
-
- def filter_character_list (self ,search_text ):
- search_text_lower =search_text .lower ()
- for i in range (self .character_list .count ()):
- item =self .character_list .item (i )
- item .setHidden (search_text_lower not in item .text ().lower ())
-
-
- def update_multithreading_label (self ,text ):
- if self .use_multithreading_checkbox .isChecked ():
- base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading")
- try :
- num_threads_val =int (text )
- if num_threads_val >0 :self .use_multithreading_checkbox .setText (f"{base_text } ({num_threads_val } Threads)")
- else :self .use_multithreading_checkbox .setText (f"{base_text } (Invalid: >0)")
- except ValueError :
- self .use_multithreading_checkbox .setText (f"{base_text } (Invalid Input)")
- else :
- self .use_multithreading_checkbox .setText (f"{self ._tr ('use_multithreading_checkbox_base_label','Use Multithreading')} (1 Thread)")
-
-
- def _handle_multithreading_toggle (self ,checked ):
- if not checked :
- self .thread_count_input .setEnabled (False )
- self .thread_count_label .setEnabled (False )
- self .use_multithreading_checkbox .setText ("Use Multithreading (1 Thread)")
- else :
- self .thread_count_input .setEnabled (True )
- self .thread_count_label .setEnabled (True )
- self .update_multithreading_label (self .thread_count_input .text ())
-
- def _update_multithreading_for_date_mode (self ):
- """
- Checks if Manga Mode is ON and 'Date Based' style is selected.
- If so, disables multithreading. Otherwise, enables it.
- """
- if not hasattr (self ,'manga_mode_checkbox')or not hasattr (self ,'use_multithreading_checkbox'):
- return
-
- manga_on =self .manga_mode_checkbox .isChecked ()
- is_sequential_style_requiring_single_thread =(
- self .manga_filename_style ==STYLE_DATE_BASED or
- self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING
- )
- if manga_on and is_sequential_style_requiring_single_thread :
- if self .use_multithreading_checkbox .isChecked ()or self .use_multithreading_checkbox .isEnabled ():
- if self .use_multithreading_checkbox .isChecked ():
- self .log_signal .emit ("ℹ️ Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.")
- self .use_multithreading_checkbox .setChecked (False )
- self .use_multithreading_checkbox .setEnabled (False )
- self ._handle_multithreading_toggle (False )
- else :
- if not self .use_multithreading_checkbox .isEnabled ():
- self .use_multithreading_checkbox .setEnabled (True )
- self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ())
-
- def update_progress_display (self ,total_posts ,processed_posts ):
- if total_posts >0 :
- progress_percent =(processed_posts /total_posts )*100
- self .progress_label .setText (self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =processed_posts ,total_posts =total_posts ,progress_percent =progress_percent ))
- elif processed_posts >0 :
- self .progress_label .setText (self ._tr ("progress_processing_post_text","Progress: Processing post {processed_posts}...").format (processed_posts =processed_posts ))
- else :
- self .progress_label .setText (self ._tr ("progress_starting_text","Progress: Starting..."))
-
- if total_posts >0 or processed_posts >0 :
- self .file_progress_label .setText ("")
-
-
- def start_download (self ,direct_api_url =None ,override_output_dir =None, is_restore=False ):
- global KNOWN_NAMES ,BackendDownloadThread ,PostProcessorWorker ,extract_post_info ,clean_folder_name ,MAX_FILE_THREADS_PER_POST_OR_WORKER
-
- if self ._is_download_active ():
- QMessageBox.warning(self, "Busy", "A download is already in progress.")
- return False
-
-
-
- if not direct_api_url and self .favorite_download_queue and not self .is_processing_favorites_queue :
- self .log_signal .emit (f"ℹ️ Detected {len (self .favorite_download_queue )} item(s) in the queue. Starting processing...")
- self .cancellation_message_logged_this_session =False
- self ._process_next_favorite_download ()
- return True
-
- if not is_restore and self.interrupted_session_data:
- self.log_signal.emit("ℹ️ New download started. Discarding previous interrupted session.")
- self._clear_session_file()
- self.interrupted_session_data = None
- self.is_restore_pending = False
- api_url =direct_api_url if direct_api_url else self .link_input .text ().strip ()
- self .download_history_candidates .clear ()
- self._update_button_states_and_connections() # Ensure buttons are updated to active state
-
-
- if self .favorite_mode_checkbox and self .favorite_mode_checkbox .isChecked ()and not direct_api_url and not api_url :
- QMessageBox .information (self ,"Favorite Mode Active",
- "Favorite Mode is active. Please use the 'Favorite Artists' or 'Favorite Posts' buttons to start downloads in this mode, or uncheck 'Favorite Mode' to use the URL input.")
- self .set_ui_enabled (True )
- return False
-
- main_ui_download_dir =self .dir_input .text ().strip ()
-
- if not api_url and not self .favorite_download_queue :
- QMessageBox .critical (self ,"Input Error","URL is required.")
- return False
- elif not api_url and self .favorite_download_queue :
- self .log_signal .emit ("ℹ️ URL input is empty, but queue has items. Processing queue...")
- self .cancellation_message_logged_this_session =False
- self ._process_next_favorite_download ()
- return True
-
- self .cancellation_message_logged_this_session =False
- use_subfolders =self .use_subfolders_checkbox .isChecked ()
- use_post_subfolders =self .use_subfolder_per_post_checkbox .isChecked ()
- compress_images =self .compress_images_checkbox .isChecked ()
- download_thumbnails =self .download_thumbnails_checkbox .isChecked ()
-
- use_multithreading_enabled_by_checkbox =self .use_multithreading_checkbox .isChecked ()
- try :
- num_threads_from_gui =int (self .thread_count_input .text ().strip ())
- if num_threads_from_gui <1 :num_threads_from_gui =1
- except ValueError :
- QMessageBox .critical (self ,"Thread Count Error","Invalid number of threads. Please enter a positive number.")
- return False
-
- if use_multithreading_enabled_by_checkbox :
- if num_threads_from_gui >MAX_THREADS :
- hard_warning_msg =(
- f"You've entered a thread count ({num_threads_from_gui }) exceeding the maximum of {MAX_THREADS }.\n\n"
- "Using an extremely high number of threads can lead to:\n"
- " - Diminishing returns (no significant speed increase).\n"
- " - Increased system instability or application crashes.\n"
- " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n"
- f"The thread count has been automatically capped to {MAX_THREADS } for stability."
- )
- QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg )
- num_threads_from_gui =MAX_THREADS
- self .thread_count_input .setText (str (MAX_THREADS ))
- self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.")
- if SOFT_WARNING_THREAD_THRESHOLD MAX_THREADS :
- hard_warning_msg =(
- f"You've entered a thread count ({num_threads_from_gui }) exceeding the maximum of {MAX_THREADS }.\n\n"
- "Using an extremely high number of threads can lead to:\n"
- " - Diminishing returns (no significant speed increase).\n"
- " - Increased system instability or application crashes.\n"
- " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n"
- f"The thread count has been automatically capped to {MAX_THREADS } for stability."
- )
- QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg )
- num_threads_from_gui =MAX_THREADS
- self .thread_count_input .setText (str (MAX_THREADS ))
- self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.")
- if SOFT_WARNING_THREAD_THRESHOLD end_page :raise ValueError ("Start page cannot be greater than end page.")
-
- if manga_mode and start_page and end_page :
- msg_box =QMessageBox (self )
- msg_box .setIcon (QMessageBox .Warning )
- msg_box .setWindowTitle ("Manga Mode & Page Range Warning")
- msg_box .setText (
- "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n"
- "Manga Mode processes posts from oldest to newest across all available pages by default.\n"
- "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n"
- "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n"
- "Do you want to proceed with this page range in Manga Mode?"
- )
- proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
- cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole )
- msg_box .setDefaultButton (proceed_button )
- msg_box .setEscapeButton (cancel_button )
- msg_box .exec_ ()
-
- if msg_box .clickedButton ()==cancel_button :
- self .log_signal .emit ("❌ Download cancelled by user due to Manga Mode & Page Range warning.")
- return False
- except ValueError as e :
- QMessageBox .critical (self ,"Page Range Error",f"Invalid page range: {e }")
- return False
- self .external_link_queue .clear ();self .extracted_links_cache =[];self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
-
- raw_character_filters_text =self .character_input .text ().strip ()
- parsed_character_filter_objects =self ._parse_character_filters (raw_character_filters_text )
-
- actual_filters_to_use_for_run =[]
-
- needs_folder_naming_validation =(use_subfolders or manga_mode )and not extract_links_only
-
- if parsed_character_filter_objects :
- actual_filters_to_use_for_run =parsed_character_filter_objects
-
- if not extract_links_only :
- self .log_signal .emit (f"ℹ️ Using character filters for matching: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
-
- filter_objects_to_potentially_add_to_known_list =[]
- 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 )
- if needs_folder_naming_validation and not cleaned_name_test :
- QMessageBox .warning (self ,"Invalid Filter Name for Folder",f"Filter name '{item_primary_name }' is invalid for a folder and will be skipped for Known.txt interaction.")
- self .log_signal .emit (f"⚠️ Skipping invalid filter for Known.txt interaction: '{item_primary_name }'")
- continue
-
- an_alias_is_already_known =False
- if any (kn_entry ["name"].lower ()==item_primary_name .lower ()for kn_entry in KNOWN_NAMES ):
- an_alias_is_already_known =True
- elif filter_item_obj ["is_group"]and needs_folder_naming_validation :
- for alias_in_filter_obj in filter_item_obj ["aliases"]:
- if any (kn_entry ["name"].lower ()==alias_in_filter_obj .lower ()or alias_in_filter_obj .lower ()in [a .lower ()for a in kn_entry ["aliases"]]for kn_entry in KNOWN_NAMES ):
- an_alias_is_already_known =True ;break
-
- if an_alias_is_already_known and filter_item_obj ["is_group"]:
- self .log_signal .emit (f"ℹ️ An alias from group '{item_primary_name }' is already known. Group will not be prompted for Known.txt addition.")
-
- should_prompt_to_add_to_known_list =(
- needs_folder_naming_validation and not manga_mode and
- not any (kn_entry ["name"].lower ()==item_primary_name .lower ()for kn_entry in KNOWN_NAMES )and
- not an_alias_is_already_known
- )
- if should_prompt_to_add_to_known_list :
- if not any (obj_to_add ["name"].lower ()==item_primary_name .lower ()for obj_to_add in filter_objects_to_potentially_add_to_known_list ):
- filter_objects_to_potentially_add_to_known_list .append (filter_item_obj )
- elif manga_mode and needs_folder_naming_validation and item_primary_name .lower ()not in {kn_entry ["name"].lower ()for kn_entry in KNOWN_NAMES }and not an_alias_is_already_known :
- self .log_signal .emit (f"ℹ️ Manga Mode: Using filter '{item_primary_name }' for this session without adding to Known Names.")
-
- if filter_objects_to_potentially_add_to_known_list :
- confirm_dialog =ConfirmAddAllDialog (filter_objects_to_potentially_add_to_known_list ,self ,self )
- dialog_result =confirm_dialog .exec_ ()
-
- if dialog_result ==CONFIRM_ADD_ALL_CANCEL_DOWNLOAD :
- self .log_signal .emit ("❌ Download cancelled by user at new name confirmation stage.")
- return False
- elif isinstance (dialog_result ,list ):
- if dialog_result :
- self .log_signal .emit (f"ℹ️ User chose to add {len (dialog_result )} new entry/entries to Known.txt.")
- for filter_obj_to_add in dialog_result :
- if filter_obj_to_add .get ("components_are_distinct_for_known_txt"):
- self .log_signal .emit (f" Processing group '{filter_obj_to_add ['name']}' to add its components individually to Known.txt.")
- for alias_component in filter_obj_to_add ["aliases"]:
- self .add_new_character (
- name_to_add =alias_component ,
- is_group_to_add =False ,
- aliases_to_add =[alias_component ],
- suppress_similarity_prompt =True
- )
- else :
- self .add_new_character (
- name_to_add =filter_obj_to_add ["name"],
- is_group_to_add =filter_obj_to_add ["is_group"],
- aliases_to_add =filter_obj_to_add ["aliases"],
- suppress_similarity_prompt =True
- )
- else :
- self .log_signal .emit ("ℹ️ User confirmed adding, but no names were selected in the dialog. No new names added to Known.txt.")
- elif dialog_result ==CONFIRM_ADD_ALL_SKIP_ADDING :
- self .log_signal .emit ("ℹ️ User chose not to add new names to Known.txt for this session.")
- else :
- self .log_signal .emit (f"ℹ️ Using character filters for link extraction: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
-
-
- if manga_mode and not actual_filters_to_use_for_run and not extract_links_only :
- msg_box =QMessageBox (self )
- msg_box .setIcon (QMessageBox .Warning )
- msg_box .setWindowTitle ("Manga Mode Filter Warning")
- msg_box .setText (
- "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)?"
- )
- proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
- cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole )
- msg_box .exec_ ()
- if msg_box .clickedButton ()==cancel_button :
- self .log_signal .emit ("❌ Download cancelled due to Manga Mode filter warning.")
- return False
- else :
- self .log_signal .emit ("⚠️ Proceeding with Manga Mode without a specific title filter.")
- self .dynamic_character_filter_holder .set_filters (actual_filters_to_use_for_run )
-
-
- creator_folder_ignore_words_for_run =None
- character_filters_are_empty =not actual_filters_to_use_for_run
- if is_full_creator_download and character_filters_are_empty :
- creator_folder_ignore_words_for_run =CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS
- log_messages .append (f" Creator Download (No Char Filter): Applying default folder name ignore list ({len (creator_folder_ignore_words_for_run )} words).")
-
- custom_folder_name_cleaned =None
- if use_subfolders and post_id_from_url and self .custom_folder_widget and self .custom_folder_widget .isVisible ()and not extract_links_only :
- 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
- else :self .log_signal .emit (f"⚠️ Invalid custom folder name ignored: '{raw_custom_name }' (resulted in empty string after cleaning).")
-
-
- 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...")
-
- if self .external_log_output :self .external_log_output .clear ()
- if self .show_external_links and not extract_links_only and backend_filter_mode !='archive':
- self .external_log_output .append ("🔗 External Links Found:")
-
- self .file_progress_label .setText ("");self .cancellation_event .clear ();self .active_futures =[]
- self .total_posts_to_process =0 ;self .processed_posts_count =0 ;self .download_counter =0 ;self .skip_counter =0
- self .progress_label .setText (self ._tr ("progress_initializing_text","Progress: Initializing..."))
-
- self .retryable_failed_files_info .clear ()
- self .permanently_failed_files_for_dialog .clear ()
-
- manga_date_file_counter_ref_for_thread =None
- if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED and not extract_links_only :
- manga_date_file_counter_ref_for_thread =None
- self .log_signal .emit (f"ℹ️ Manga Date Mode: File counter will be initialized by the download thread.")
-
- manga_global_file_counter_ref_for_thread =None
- if manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not extract_links_only :
- manga_global_file_counter_ref_for_thread =None
- self .log_signal .emit (f"ℹ️ Manga Title+GlobalNum Mode: File counter will be initialized by the download thread (starts at 1).")
-
- effective_num_post_workers =1
-
- effective_num_file_threads_per_worker =1
-
- if post_id_from_url :
- if use_multithreading_enabled_by_checkbox :
- effective_num_file_threads_per_worker =max (1 ,min (num_threads_from_gui ,MAX_FILE_THREADS_PER_POST_OR_WORKER ))
- else :
- if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED :
- effective_num_post_workers =1
- elif manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
- effective_num_post_workers =1
- effective_num_file_threads_per_worker =1
- elif use_multithreading_enabled_by_checkbox :
- effective_num_post_workers =max (1 ,min (num_threads_from_gui ,MAX_THREADS ))
- effective_num_file_threads_per_worker =1
-
- if not extract_links_only :log_messages .append (f" Save Location: {effective_output_dir_for_run }")
-
- if post_id_from_url :
- log_messages .append (f" Mode: Single Post")
- log_messages .append (f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker } concurrent file(s)")
- 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 ()
-
- if manga_mode :
- log_messages .append (f" Page Range: {pr_log if pr_log else 'All'} (Manga Mode - Oldest Posts Processed First within range)")
- else :
- log_messages .append (f" Page Range: {pr_log if pr_log else 'All'}")
-
-
- if not extract_links_only :
- log_messages .append (f" Subfolders: {'Enabled'if use_subfolders else 'Disabled'}")
- if use_subfolders :
- if custom_folder_name_cleaned :log_messages .append (f" Custom Folder (Post): '{custom_folder_name_cleaned }'")
- if actual_filters_to_use_for_run :
- log_messages .append (f" Character Filters: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
- 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)")
-
-
- log_messages .extend ([
- f" File Type Filter: {user_selected_filter_text } (Backend processing as: {backend_filter_mode })",
- 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 '')}",
- f" Skip Words (posts/files): {', '.join (skip_words_list )if skip_words_list else 'None'}",
- f" Skip Words Scope: {current_skip_words_scope .capitalize ()}",
- f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}",
- f" Compress Images: {'Enabled'if compress_images else 'Disabled'}",
- f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}"
- ])
- log_messages .append (f" Scan Post Content for Images: {'Enabled'if scan_content_for_images else 'Disabled'}")
- else :
- 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'}")
-
- if manga_mode :
- 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'}")
- if actual_filters_to_use_for_run :
- log_messages .append (f" ↳ Manga Character Filter (for naming/folder): {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
- log_messages .append (f" ↳ Manga Duplicates: Will be renamed with numeric suffix if names clash (e.g., _1, _2).")
-
- log_messages .append (f" Use Cookie ('cookies.txt'): {'Enabled'if use_cookie_from_checkbox else 'Disabled'}")
- if use_cookie_from_checkbox and cookie_text_from_input :
- log_messages .append (f" ↳ Cookie Text Provided: Yes (length: {len (cookie_text_from_input )})")
- elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend :
- log_messages .append (f" ↳ Cookie File Selected: {os .path .basename (selected_cookie_file_path_for_backend )}")
- should_use_multithreading_for_posts =use_multithreading_enabled_by_checkbox and not post_id_from_url
- if manga_mode and (self .manga_filename_style ==STYLE_DATE_BASED or self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING )and not post_id_from_url :
- enforced_by_style ="Date Mode"if self .manga_filename_style ==STYLE_DATE_BASED else "Title+GlobalNum Mode"
- should_use_multithreading_for_posts =False
- log_messages .append (f" Threading: Single-threaded (posts) - Enforced by Manga {enforced_by_style } (Actual workers: {effective_num_post_workers if effective_num_post_workers >1 else 1 })")
- else :
- log_messages .append (f" Threading: {'Multi-threaded (posts)'if should_use_multithreading_for_posts else 'Single-threaded (posts)'}")
- if should_use_multithreading_for_posts :
- log_messages .append (f" Number of Post Worker Threads: {effective_num_post_workers }")
- log_messages .append ("="*40 )
- for msg in log_messages :self .log_signal .emit (msg )
-
- self .set_ui_enabled (False )
-
-
- from downloader_utils import FOLDER_NAME_STOP_WORDS
-
-
- args_template ={
- 'api_url_input':api_url ,
- 'download_root':effective_output_dir_for_run ,
- 'output_dir':effective_output_dir_for_run ,
- 'known_names':list (KNOWN_NAMES ),
- 'known_names_copy':list (KNOWN_NAMES ),
- 'filter_character_list':actual_filters_to_use_for_run ,
- 'filter_mode':backend_filter_mode ,
- 'skip_zip':effective_skip_zip ,
- 'skip_rar':effective_skip_rar ,
- 'use_subfolders':use_subfolders ,
- 'use_post_subfolders':use_post_subfolders ,
- 'compress_images':compress_images ,
- 'download_thumbnails':download_thumbnails ,
- 'service':service ,
- 'user_id':user_id ,
- 'downloaded_files':self .downloaded_files ,
- 'downloaded_files_lock':self .downloaded_files_lock ,
- 'downloaded_file_hashes':self .downloaded_file_hashes ,
- 'downloaded_file_hashes_lock':self .downloaded_file_hashes_lock ,
- 'skip_words_list':skip_words_list ,
- 'skip_words_scope':current_skip_words_scope ,
- 'remove_from_filename_words_list':remove_from_filename_words_list ,
- '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':FOLDER_NAME_STOP_WORDS ,
- 'cancellation_event':self .cancellation_event ,
- 'manga_date_prefix':manga_date_prefix_text ,
- 'dynamic_character_filter_holder':self .dynamic_character_filter_holder ,
- 'pause_event':self .pause_event ,
- 'scan_content_for_images':scan_content_for_images ,
- 'manga_filename_style':self .manga_filename_style ,
- 'num_file_threads_for_worker':effective_num_file_threads_per_worker ,
- 'manga_date_file_counter_ref':manga_date_file_counter_ref_for_thread ,
- 'allow_multipart_download':allow_multipart ,
- 'cookie_text':cookie_text_from_input ,
- 'selected_cookie_file':selected_cookie_file_path_for_backend ,
- 'manga_global_file_counter_ref':manga_global_file_counter_ref_for_thread ,
- 'app_base_dir':app_base_dir_for_cookies ,
- 'use_cookie':use_cookie_for_this_run ,
- 'session_file_path': self.session_file_path,
- 'session_lock': self.session_lock,
- 'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run ,
- }
-
- args_template ['override_output_dir']=override_output_dir
- try :
- if should_use_multithreading_for_posts :
- self .log_signal .emit (f" Initializing multi-threaded {current_mode_log_text .lower ()} with {effective_num_post_workers } post workers...")
- args_template ['emitter']=self .worker_to_gui_queue
- self .start_multi_threaded_download (num_post_workers =effective_num_post_workers ,**args_template )
- else :
- self .log_signal .emit (f" Initializing single-threaded {'link extraction'if extract_links_only else 'download'}...")
- dt_expected_keys =[
- 'api_url_input','output_dir','known_names_copy','cancellation_event',
- 'filter_character_list','filter_mode','skip_zip','skip_rar',
- 'use_subfolders','use_post_subfolders','custom_folder_name',
- 'compress_images','download_thumbnails','service','user_id',
- 'downloaded_files','downloaded_file_hashes','pause_event','remove_from_filename_words_list',
- 'downloaded_files_lock','downloaded_file_hashes_lock','dynamic_character_filter_holder', 'session_file_path',
- 'session_lock',
- 'skip_words_list','skip_words_scope','char_filter_scope',
- 'show_external_links','extract_links_only','num_file_threads_for_worker',
- 'start_page','end_page','target_post_id_from_initial_url',
- 'manga_date_file_counter_ref',
- 'manga_global_file_counter_ref','manga_date_prefix',
- 'manga_mode_active','unwanted_keywords','manga_filename_style','scan_content_for_images',
- 'allow_multipart_download','use_cookie','cookie_text','app_base_dir','selected_cookie_file','override_output_dir'
- ]
- args_template ['skip_current_file_flag']=None
- single_thread_args ={key :args_template [key ]for key in dt_expected_keys if key in args_template }
- self .start_single_threaded_download (**single_thread_args )
- except Exception as e :
- self._update_button_states_and_connections() # Re-enable UI if start fails
- self .log_signal .emit (f"❌ CRITICAL ERROR preparing download: {e }\n{traceback .format_exc ()}")
- QMessageBox .critical (self ,"Start Error",f"Failed to start process:\n{e }")
- self .download_finished (0 ,0 ,False ,[])
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
- return True
-
- def restore_download(self):
- """Initiates the download restoration process."""
- if self._is_download_active():
- QMessageBox.warning(self, "Busy", "A download is already in progress.")
- return
-
- if not self.interrupted_session_data:
- self.log_signal.emit("❌ No session data to restore.")
- self._clear_session_and_reset_ui()
- return
-
- self.log_signal.emit("🔄 Restoring download session...")
- # The main start_download function now handles the restore logic
- self.is_restore_pending = True # Set state to indicate restore is in progress
- self.start_download(is_restore=True)
-
- def start_single_threaded_download (self ,**kwargs ):
- global BackendDownloadThread
- try :
- self .download_thread =BackendDownloadThread (**kwargs )
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
- 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 )
- if hasattr (self .download_thread ,'missed_character_post_signal'):
- self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post )
- if hasattr (self .download_thread ,'retryable_file_failed_signal'):
-
- if hasattr (self .download_thread ,'file_successfully_downloaded_signal'):
- self .download_thread .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
- if hasattr (self .download_thread ,'post_processed_for_history_signal'):
- self .download_thread .post_processed_for_history_signal .connect (self ._add_to_history_candidates )
- self .download_thread .retryable_file_failed_signal .connect (self ._handle_retryable_file_failure )
- if hasattr (self .download_thread ,'permanent_file_failed_signal'):
- self .download_thread .permanent_file_failed_signal .connect (self ._handle_permanent_file_failure_from_thread )
- self .download_thread .start ()
- self .log_signal .emit ("✅ Single download thread (for posts) started.")
- self._update_button_states_and_connections() # Update buttons after thread starts
- except Exception as e :
- 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 }")
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
-
- def _show_error_files_dialog (self ):
- """Shows the dialog with files that were skipped due to errors."""
- if not self .permanently_failed_files_for_dialog :
- QMessageBox .information (
- self ,
- self ._tr ("no_errors_logged_title","No Errors Logged"),
- self ._tr ("no_errors_logged_message","No files were recorded as skipped due to errors in the last session or after retries."))
- return
- dialog =ErrorFilesDialog (self .permanently_failed_files_for_dialog ,self ,self )
- dialog .retry_selected_signal .connect (self ._handle_retry_from_error_dialog )
- dialog .exec_ ()
- def _handle_retry_from_error_dialog (self ,selected_files_to_retry ):
- self ._start_failed_files_retry_session (files_to_retry_list =selected_files_to_retry )
-
- def _handle_retryable_file_failure (self ,list_of_retry_details ):
- """Appends details of files that failed but might be retryable later."""
- if list_of_retry_details :
- self .retryable_failed_files_info .extend (list_of_retry_details )
-
- def _handle_permanent_file_failure_from_thread (self ,list_of_permanent_failure_details ):
- """Handles permanently failed files signaled by the single BackendDownloadThread."""
- if list_of_permanent_failure_details :
- self .permanently_failed_files_for_dialog .extend (list_of_permanent_failure_details )
- self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
-
- def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
- """Helper to prepare and submit a single post processing task to the thread pool."""
- global PostProcessorWorker
- if not isinstance (post_data_item ,dict ):
- self .log_signal .emit (f"⚠️ Skipping invalid post data item (not a dict): {type (post_data_item )}");
- return False
-
- 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 =='emitter':worker_init_args [key ]=emitter_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 :
- self .log_signal .emit (f"❌ CRITICAL ERROR: Missing keys for PostProcessorWorker: {', '.join (missing_keys )}");
- self .cancellation_event .set ()
- return False
-
- 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 )
- return True
- else :
- self .log_signal .emit ("⚠️ Thread pool not available. Cannot submit task.");
- self .cancellation_event .set ()
- return False
- 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 ()
- return False
- except RuntimeError :
- self .log_signal .emit (f"⚠️ RuntimeError submitting task (pool likely shutting down).")
- self .cancellation_event .set ()
- return False
- except Exception as e :
- self .log_signal .emit (f"❌ Error submitting post {post_data_item .get ('id','N/A')} to worker: {e }")
- self .cancellation_event .set ()
- return False
-
- def _load_ui_from_settings_dict(self, settings: dict):
- """Populates the UI with values from a settings dictionary."""
- # Text inputs
- self.link_input.setText(settings.get('api_url', ''))
- self.dir_input.setText(settings.get('output_dir', ''))
- self.character_input.setText(settings.get('character_filter_text', ''))
- self.skip_words_input.setText(settings.get('skip_words_text', ''))
- self.remove_from_filename_input.setText(settings.get('remove_words_text', ''))
- self.custom_folder_input.setText(settings.get('custom_folder_name', ''))
- self.cookie_text_input.setText(settings.get('cookie_text', ''))
- if hasattr(self, 'manga_date_prefix_input'):
- self.manga_date_prefix_input.setText(settings.get('manga_date_prefix', ''))
-
- # Numeric inputs
- self.thread_count_input.setText(str(settings.get('num_threads', 4)))
- self.start_page_input.setText(str(settings.get('start_page', '')) if settings.get('start_page') is not None else '')
- self.end_page_input.setText(str(settings.get('end_page', '')) if settings.get('end_page') is not None else '')
-
- # Checkboxes
- for checkbox_name, key in self.get_checkbox_map().items():
- checkbox = getattr(self, checkbox_name, None)
- if checkbox:
- checkbox.setChecked(settings.get(key, False))
-
- # Radio buttons
- if settings.get('only_links'): self.radio_only_links.setChecked(True)
- else:
- filter_mode = settings.get('filter_mode', 'all')
- if filter_mode == 'image': self.radio_images.setChecked(True)
- elif filter_mode == 'video': self.radio_videos.setChecked(True)
- elif filter_mode == 'archive': self.radio_only_archives.setChecked(True)
- elif filter_mode == 'audio' and hasattr(self, 'radio_only_audio'): self.radio_only_audio.setChecked(True)
- else: self.radio_all.setChecked(True)
-
- # Toggle button states
- self.skip_words_scope = settings.get('skip_words_scope', SKIP_SCOPE_POSTS)
- self.char_filter_scope = settings.get('char_filter_scope', CHAR_SCOPE_TITLE)
- self.manga_filename_style = settings.get('manga_filename_style', STYLE_POST_TITLE)
- self.allow_multipart_download_setting = settings.get('allow_multipart_download', False)
-
- # Update button texts after setting states
- self._update_skip_scope_button_text()
- self._update_char_filter_scope_button_text()
- self._update_manga_filename_style_button_text()
- self._update_multipart_toggle_button_text()
-
- def start_multi_threaded_download (self ,num_post_workers ,**kwargs ):
- global PostProcessorWorker
- if self .thread_pool is None :
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
- self .thread_pool =ThreadPoolExecutor (max_workers =num_post_workers ,thread_name_prefix ='PostWorker_')
-
- self .active_futures =[]
- self .processed_posts_count =0 ;self .total_posts_to_process =0 ;self .download_counter =0 ;self .skip_counter =0
- self .all_kept_original_filenames =[]
- self .is_fetcher_thread_running =True
-
- fetcher_thread =threading .Thread (
- target =self ._fetch_and_queue_posts ,
- args =(kwargs ['api_url_input'],kwargs ,num_post_workers ),
- daemon =True ,
- name ="PostFetcher"
- )
- fetcher_thread .start ()
- self .log_signal .emit (f"✅ Post fetcher thread started. {num_post_workers } post worker threads initializing...")
- self._update_button_states_and_connections() # Update buttons after fetcher thread starts
-
- def _fetch_and_queue_posts (self ,api_url_input_for_fetcher ,worker_args_template ,num_post_workers ):
- 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 )
- emitter_for_worker =worker_args_template .get ('emitter')
-
- is_restore = self.interrupted_session_data is not None
- if is_restore:
- all_posts_data = self.interrupted_session_data['download_state']['all_posts_data']
- processed_ids = set(self.interrupted_session_data['download_state']['processed_post_ids'])
- posts_to_process = [p for p in all_posts_data if p.get('id') not in processed_ids]
- self.log_signal.emit(f"Restoring session. {len(posts_to_process)} posts remaining out of {len(all_posts_data)}.")
- self.total_posts_to_process = len(all_posts_data)
- self.processed_posts_count = len(processed_ids)
- self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
+ # Ensure the window is not larger than the screen
+ app_width = min(app_width, screen_width)
+ app_height = min(app_height, screen_height)
- # Re-assign all_posts_data to only what needs processing
- all_posts_data = posts_to_process
-
- if not emitter_for_worker :
- self .log_signal .emit ("❌ CRITICAL ERROR: Emitter (queue) missing for worker in _fetch_and_queue_posts.");
- self .finished_signal .emit (0 ,0 ,True ,[]);
- return
-
- try:
- self.log_signal.emit(" Fetching post data from API (this may take a moment for large feeds)...")
- if not is_restore: # Only fetch new data if not restoring
- post_generator = download_from_api(
- api_url_input_for_fetcher,
- logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"),
- start_page=worker_args_template.get('start_page'),
- end_page=worker_args_template.get('end_page'),
- manga_mode=manga_mode_active_for_fetch,
- cancellation_event=self.cancellation_event,
- pause_event=worker_args_template.get('pause_event'),
- use_cookie=worker_args_template.get('use_cookie'),
- cookie_text=worker_args_template.get('cookie_text'),
- selected_cookie_file=worker_args_template.get('selected_cookie_file'),
- app_base_dir=worker_args_template.get('app_base_dir'),
- manga_filename_style_for_sort_check=(
- worker_args_template.get('manga_filename_style')
- if manga_mode_active_for_fetch
- else None
- )
- )
-
- for posts_batch in post_generator:
- if self.cancellation_event.is_set():
- fetch_error_occurred = True; self.log_signal.emit(" Post fetching cancelled by user."); break
- 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:
- self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts so far...")
- else:
- fetch_error_occurred = True; self.log_signal.emit(f"❌ API fetcher returned non-list type: {type(posts_batch)}"); break
-
- if not fetch_error_occurred and not self.cancellation_event.is_set():
- self.log_signal.emit(f"✅ Post fetching complete. Total posts to process: {self.total_posts_to_process}")
-
- # Get a clean, serializable dictionary of UI settings
- output_dir_for_session = worker_args_template.get('output_dir', self.dir_input.text().strip())
- ui_settings_for_session = self._get_current_ui_settings_as_dict(
- api_url_override=api_url_input_for_fetcher,
- output_dir_override=output_dir_for_session
- )
-
- # Save initial session state
- session_data = {
- "timestamp": datetime.datetime.now().isoformat(),
- "ui_settings": ui_settings_for_session,
- "download_state": {
- "all_posts_data": all_posts_data,
- "processed_post_ids": []
- }
- }
- self._save_session_file(session_data)
-
- # From here, all_posts_data is the list of posts to process (either new or restored)
- unique_posts_dict ={}
- for post in all_posts_data :
- post_id =post .get ('id')
- if post_id is not None :
- if post_id not in unique_posts_dict :
- unique_posts_dict [post_id ]=post
- else :
- self .log_signal .emit (f"⚠️ Skipping post with no ID: {post .get ('title','Untitled')}")
-
- posts_to_process_final = list(unique_posts_dict.values())
-
- if not is_restore:
- self.total_posts_to_process = len(posts_to_process_final)
- self.log_signal.emit(f" Processed {len(posts_to_process_final)} unique posts after de-duplication.")
- if len(posts_to_process_final) < len(all_posts_data):
- self.log_signal.emit(f" Note: {len(all_posts_data) - len(posts_to_process_final)} duplicate post IDs were removed.")
- all_posts_data = posts_to_process_final
-
- except TypeError as te :
- 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
- except RuntimeError as re_err :
- self .log_signal .emit (f"ℹ️ Post fetching runtime error (likely cancellation or API issue): {re_err }");fetch_error_occurred =True
- except Exception as e :
- self .log_signal .emit (f"❌ Error during post fetching: {e }\n{traceback .format_exc (limit =2 )}");fetch_error_occurred =True
-
- finally :
- self .is_fetcher_thread_running =False
- self .log_signal .emit (f"ℹ️ Post fetcher thread (_fetch_and_queue_posts) has completed its task. is_fetcher_thread_running set to False.")
-
- if self .cancellation_event .is_set ()or fetch_error_occurred :
- self .finished_signal .emit (self .download_counter ,self .skip_counter ,self .cancellation_event .is_set (),self .all_kept_original_filenames )
- if self .thread_pool :self .thread_pool .shutdown (wait =False ,cancel_futures =True );self .thread_pool =None
- return
-
- if not all_posts_data:
- self .log_signal .emit ("😕 No posts found or fetched to process.")
- self .finished_signal .emit (0 ,0 ,False ,[])
- return
-
- self .log_signal .emit (f" Preparing to submit {self .total_posts_to_process } post processing tasks to thread pool...")
- if not is_restore:
- self.processed_posts_count = 0
- self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
-
- num_file_dl_threads_for_each_worker =worker_args_template .get ('num_file_threads_for_worker',1 )
-
-
- ppw_expected_keys =[
- 'post_data','download_root','known_names','filter_character_list','unwanted_keywords',
- 'filter_mode','skip_zip','skip_rar','use_subfolders','use_post_subfolders',
- 'target_post_id_from_initial_url','custom_folder_name','compress_images','emitter','pause_event',
- 'download_thumbnails','service','user_id','api_url_input',
- 'cancellation_event','downloaded_files','downloaded_file_hashes',
- 'downloaded_files_lock','downloaded_file_hashes_lock','remove_from_filename_words_list','dynamic_character_filter_holder',
- 'skip_words_list','skip_words_scope','char_filter_scope',
- 'show_external_links','extract_links_only','allow_multipart_download','use_cookie','cookie_text',
- 'app_base_dir','selected_cookie_file','override_output_dir',
- 'num_file_threads','skip_current_file_flag','manga_date_file_counter_ref','scan_content_for_images',
- 'manga_mode_active','manga_filename_style','manga_date_prefix',
- 'manga_global_file_counter_ref'
- ,'creator_download_folder_ignore_words'
- , 'session_file_path', 'session_lock'
- ]
-
- ppw_optional_keys_with_defaults ={
- 'skip_words_list','skip_words_scope','char_filter_scope','remove_from_filename_words_list',
- 'show_external_links','extract_links_only','duplicate_file_mode',
- 'num_file_threads','skip_current_file_flag','manga_mode_active','manga_filename_style','manga_date_prefix',
- 'manga_date_file_counter_ref','use_cookie','cookie_text','app_base_dir','selected_cookie_file'
- }
- if num_post_workers >POST_WORKER_BATCH_THRESHOLD and self .total_posts_to_process >POST_WORKER_NUM_BATCHES :
- self .log_signal .emit (f" High thread count ({num_post_workers }) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES } parts.")
-
- import math
- tasks_submitted_in_batch_segment =0
- batch_size =math .ceil (self .total_posts_to_process /POST_WORKER_NUM_BATCHES )
- submitted_count_in_batching =0
-
- for batch_num in range (POST_WORKER_NUM_BATCHES ):
- if self .cancellation_event .is_set ():break
-
- if self .pause_event and self .pause_event .is_set ():
- self .log_signal .emit (f" [Fetcher] Batch submission paused before batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }...")
- while self .pause_event .is_set ():
- if self .cancellation_event .is_set ():
- self .log_signal .emit (" [Fetcher] Batch submission cancelled while paused.")
- break
- time .sleep (0.5 )
- if self .cancellation_event .is_set ():break
- if not self .cancellation_event .is_set ():
- self .log_signal .emit (f" [Fetcher] Batch submission resumed. Processing batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }.")
-
- start_index =batch_num *batch_size
- end_index =min ((batch_num +1 )*batch_size ,self .total_posts_to_process )
- current_batch_posts =all_posts_data [start_index :end_index ]
-
- if not current_batch_posts :continue
-
- self .log_signal .emit (f" Submitting batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES } ({len (current_batch_posts )} posts) to pool...")
- for post_data_item in current_batch_posts :
- if self .cancellation_event .is_set ():break
- success =self ._submit_post_to_worker_pool (post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults )
- if success :
- submitted_count_in_batching +=1
- tasks_submitted_in_batch_segment +=1
- if tasks_submitted_in_batch_segment %10 ==0 :
- time .sleep (0.005 )
- tasks_submitted_in_batch_segment =0
- elif self .cancellation_event .is_set ():
- break
-
- if self .cancellation_event .is_set ():break
-
- if batch_num 0 and self .processed_posts_count >=self .total_posts_to_process :
- if all (f .done ()for f in self .active_futures ):
- QApplication .processEvents ()
- self .log_signal .emit ("🏁 All submitted post tasks have completed or failed.")
- self .finished_signal .emit (self .download_counter ,self .skip_counter ,self .cancellation_event .is_set (),self .all_kept_original_filenames )
-
- 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 _finalize_download_history (self ):
- """Processes candidates and selects the final 3 history entries.
- Only updates final_download_history_entries if new candidates are available.
- """
- if not self .download_history_candidates :
-
-
- self .log_signal .emit ("ℹ️ No new history candidates from this session. Preserving existing history.")
-
-
- self .download_history_candidates .clear ()
- return
-
- candidates =list (self .download_history_candidates )
- now =datetime .datetime .now (datetime .timezone .utc )
-
- def get_sort_key (entry ):
- upload_date_str =entry .get ('upload_date_str')
- if not upload_date_str :
- return datetime .timedelta .max
- try :
-
- upload_dt =datetime .datetime .fromisoformat (upload_date_str .replace ('Z','+00:00'))
- if upload_dt .tzinfo is None :
- upload_dt =upload_dt .replace (tzinfo =datetime .timezone .utc )
- return abs (now -upload_dt )
- except ValueError :
- return datetime .timedelta .max
-
- candidates .sort (key =get_sort_key )
- self .final_download_history_entries =candidates [:3 ]
- self .log_signal .emit (f"ℹ️ Finalized download history: {len (self .final_download_history_entries )} entries selected.")
- self .download_history_candidates .clear ()
-
-
- self ._save_persistent_history ()
-
- def _get_configurable_widgets_on_pause (self ):
- """Returns a list of widgets that should be re-enabled when paused."""
- return [
- self .dir_input ,self .dir_button ,
- self .character_input ,self .char_filter_scope_toggle_button ,
- self .skip_words_input ,self .skip_scope_toggle_button ,
- self .remove_from_filename_input ,
- self .radio_all ,self .radio_images ,self .radio_videos ,
- self .radio_only_archives ,self .radio_only_links ,
- self .skip_zip_checkbox ,self .skip_rar_checkbox ,
- self .download_thumbnails_checkbox ,self .compress_images_checkbox ,
- self .use_subfolders_checkbox ,self .use_subfolder_per_post_checkbox ,
- self .manga_mode_checkbox ,
- self .manga_rename_toggle_button ,
- self .cookie_browse_button ,
- self .favorite_mode_checkbox ,
- self .multipart_toggle_button ,
- self .cookie_text_input ,
- self .scan_content_images_checkbox ,
- self .use_cookie_checkbox ,
- self .external_links_checkbox
- ]
-
- def set_ui_enabled (self ,enabled ):
- all_potentially_toggleable_widgets =[
- self .link_input ,self .dir_input ,self .dir_button ,
- self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ,
- self .character_input ,self .char_filter_scope_toggle_button ,self .character_filter_widget ,
- self .filters_and_custom_folder_container_widget ,
- self .custom_folder_label ,self .custom_folder_input ,
- self .skip_words_input ,self .skip_scope_toggle_button ,self .remove_from_filename_input ,
- self .radio_all ,self .radio_images ,self .radio_videos ,self .radio_only_archives ,self .radio_only_links ,
- self .skip_zip_checkbox ,self .skip_rar_checkbox ,self .download_thumbnails_checkbox ,self .compress_images_checkbox ,
- self .use_subfolders_checkbox ,self .use_subfolder_per_post_checkbox ,self .scan_content_images_checkbox ,
- self .use_multithreading_checkbox ,self .thread_count_input ,self .thread_count_label ,
- self .favorite_mode_checkbox ,
- 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
- ]
-
- widgets_to_enable_on_pause =self ._get_configurable_widgets_on_pause ()
- is_fav_mode_active =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
- download_is_active_or_paused =not enabled
-
- if not enabled :
- if self .bottom_action_buttons_stack :
- self .bottom_action_buttons_stack .setCurrentIndex (0 )
-
- if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
- self .log_signal .emit ("ℹ️ Cancelling active Mega download due to UI state change.")
- self .external_link_download_thread .cancel ()
- else :
- pass
-
-
- for widget in all_potentially_toggleable_widgets :
- if not widget :continue
-
-
- if widget is self .favorite_mode_artists_button or widget is self .favorite_mode_posts_button :continue
- elif self .is_paused and widget in widgets_to_enable_on_pause :
- widget .setEnabled (True )
- elif widget is self .favorite_mode_checkbox :
- widget .setEnabled (enabled )
- elif widget is self .use_cookie_checkbox and is_fav_mode_active :
- widget .setEnabled (False )
- elif widget is self .use_cookie_checkbox and self .is_paused and widget in widgets_to_enable_on_pause :
- widget .setEnabled (True )
- else :
- widget .setEnabled (enabled )
-
- if self .link_input :
- self .link_input .setEnabled (enabled and not is_fav_mode_active )
-
-
-
- if not enabled :
- if self .favorite_mode_artists_button :
- self .favorite_mode_artists_button .setEnabled (False )
- if self .favorite_mode_posts_button :
- self .favorite_mode_posts_button .setEnabled (False )
-
- if self .download_btn :
- self .download_btn .setEnabled (enabled and not is_fav_mode_active )
-
-
- if self .external_links_checkbox :
- 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 ()
- is_only_audio =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
- can_enable_ext_links =enabled and not is_only_links and not is_only_archives and not is_only_audio
- self .external_links_checkbox .setEnabled (can_enable_ext_links )
- if self .is_paused and not is_only_links and not is_only_archives and not is_only_audio :
- self .external_links_checkbox .setEnabled (True )
- if hasattr (self ,'use_cookie_checkbox'):
- self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ())
-
- if self .log_verbosity_toggle_button :self .log_verbosity_toggle_button .setEnabled (True )
-
- multithreading_currently_on =self .use_multithreading_checkbox .isChecked ()
- if self .thread_count_input :self .thread_count_input .setEnabled (enabled and multithreading_currently_on )
- if self .thread_count_label :self .thread_count_label .setEnabled (enabled and multithreading_currently_on )
-
- subfolders_currently_on =self .use_subfolders_checkbox .isChecked ()
- if self .use_subfolder_per_post_checkbox :
- self .use_subfolder_per_post_checkbox .setEnabled (enabled or (self .is_paused and self .use_subfolder_per_post_checkbox in widgets_to_enable_on_pause ))
- if self .cancel_btn :self .cancel_btn .setEnabled (download_is_active_or_paused )
- if self .pause_btn :
- self .pause_btn .setEnabled (download_is_active_or_paused )
- if download_is_active_or_paused :
- 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 .setToolTip (self ._tr ("resume_download_button_tooltip","Click to resume the download.")if self .is_paused else self ._tr ("pause_download_button_tooltip","Click to pause the download."))
- else :
- self .pause_btn .setText (self ._tr ("pause_download_button_text","⏸️ Pause Download"))
- self .pause_btn .setToolTip (self ._tr ("pause_download_button_tooltip","Click to pause the ongoing download process."))
- self .is_paused =False
- if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_button_text","❌ Cancel & Reset UI"))
- if enabled :
- if self .pause_event :self .pause_event .clear ()
- if enabled or self .is_paused :
- self ._handle_multithreading_toggle (multithreading_currently_on )
- self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
- self .update_custom_folder_visibility (self .link_input .text ())
- self .update_page_range_enabled_state ()
- if self .radio_group and self .radio_group .checkedButton ():
- self ._handle_filter_mode_change (self .radio_group .checkedButton (),True )
- self .update_ui_for_subfolders (subfolders_currently_on )
- self ._handle_favorite_mode_toggle (is_fav_mode_active )
-
- def _handle_pause_resume_action (self ):
- if self ._is_download_active ():
- self .is_paused =not self .is_paused
- if self .is_paused :
- if self .pause_event :self .pause_event .set ()
- self .log_signal .emit ("ℹ️ Download paused by user. Some settings can now be changed for subsequent operations.")
- else :
- if self .pause_event :self .pause_event .clear ()
- self .log_signal .emit ("ℹ️ Download resumed by user.")
- self .set_ui_enabled (False )
-
- 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...")
- 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 ();
- 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 );
- 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 )
- if self .manga_mode_checkbox :self .manga_mode_checkbox .setChecked (False )
- if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setChecked (self .use_cookie_setting )
- if not (hasattr (self ,'use_cookie_checkbox')and self .use_cookie_checkbox .isChecked ()):
- self .selected_cookie_filepath =None
- if hasattr (self ,'cookie_text_input'):self .cookie_text_input .setText (self .cookie_text_setting if self .use_cookie_setting else "")
- self .allow_multipart_download_setting =False
- self ._update_multipart_toggle_button_text ()
-
- self .skip_words_scope =SKIP_SCOPE_POSTS
- self ._update_skip_scope_button_text ()
-
- if hasattr (self ,'manga_date_prefix_input'):self .manga_date_prefix_input .clear ()
-
- self .char_filter_scope =CHAR_SCOPE_TITLE
- self ._update_char_filter_scope_button_text ()
-
- self .manga_filename_style =STYLE_POST_TITLE
- self ._update_manga_filename_style_button_text ()
- if preserve_url is not None :
- self .link_input .setText (preserve_url )
- if preserve_dir is not None :
- self .dir_input .setText (preserve_dir )
- self .external_link_queue .clear ();self .extracted_links_cache =[]
- self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
- if self .pause_event :self .pause_event .clear ()
- self.is_restore_pending = False
- 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 .is_paused =False
- self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ())
-
- self._update_button_states_and_connections() # Reset button states and connections
- self .favorite_download_queue .clear ()
- self .is_processing_favorites_queue =False
-
- self .only_links_log_display_mode =LOG_DISPLAY_LINKS
-
- if hasattr (self ,'link_input'):
- if self .download_extracted_links_button :
- self .download_extracted_links_button .setEnabled (False )
-
- self .last_link_input_text_for_queue_sync =self .link_input .text ()
- self .permanently_failed_files_for_dialog .clear ()
- self .filter_character_list (self .character_search_input .text ())
- self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
- self ._update_favorite_scope_button_text ()
-
- self .set_ui_enabled (True )
- self.interrupted_session_data = None # Clear session data from memory
- self .update_custom_folder_visibility (self .link_input .text ())
- self .update_page_range_enabled_state ()
- self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False )
- if hasattr (self ,'favorite_mode_checkbox'):
- self ._handle_favorite_mode_toggle (False )
-
- self .log_signal .emit ("✅ Soft UI reset complete. Preserved URL and Directory (if provided).")
-
- def _update_log_display_mode_button_text (self ):
- if hasattr (self ,'log_display_mode_toggle_button'):
- if self .only_links_log_display_mode ==LOG_DISPLAY_LINKS :
- self .log_display_mode_toggle_button .setText (self ._tr ("log_display_mode_links_view_text","🔗 Links View"))
- self .log_display_mode_toggle_button .setToolTip (
- "Current View: Extracted Links.\n"
- "After Mega download, Mega log is shown THEN links are appended.\n"
- "Click to switch to 'Download Progress View'."
- )
- else :
- self .log_display_mode_toggle_button .setText (self ._tr ("log_display_mode_progress_view_text","⬇️ Progress View"))
- self .log_display_mode_toggle_button .setToolTip (
- "Current View: Mega Download Progress.\n"
- "After Mega download, ONLY Mega log is shown (links hidden).\n"
- "Click to switch to 'Extracted Links View'."
- )
-
- def _toggle_log_display_mode (self ):
- self .only_links_log_display_mode =LOG_DISPLAY_DOWNLOAD_PROGRESS if self .only_links_log_display_mode ==LOG_DISPLAY_LINKS else LOG_DISPLAY_LINKS
- self ._update_log_display_mode_button_text ()
- self ._filter_links_log ()
-
- def cancel_download_button_action (self ):
- 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
- self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...")
-
- self._clear_session_file() # Clear session file on explicit cancel
- if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
- self .log_signal .emit (" Cancelling active External Link download thread...")
- self .external_link_download_thread .cancel ()
-
- current_url =self .link_input .text ()
- current_dir =self .dir_input .text ()
-
- self .cancellation_event .set ()
- self .is_fetcher_thread_running =False
- if self .download_thread and self .download_thread .isRunning ():self .download_thread .requestInterruption ();self .log_signal .emit (" Signaled single download thread to interrupt.")
- 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
- self .active_futures =[]
-
- self .external_link_queue .clear ();self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
-
- self ._perform_soft_ui_reset (preserve_url =current_url ,preserve_dir =current_dir )
-
- self .progress_label .setText (f"{self ._tr ('status_cancelled_by_user','Cancelled by user')}. {self ._tr ('ready_for_new_task_text','Ready for new task.')}")
- self .file_progress_label .setText ("")
- if self .pause_event :self .pause_event .clear ()
- self .log_signal .emit ("ℹ️ UI reset. Ready for new operation. Background tasks are being terminated.")
- self .is_paused =False
- if hasattr (self ,'retryable_failed_files_info')and self .retryable_failed_files_info :
- self .log_signal .emit (f" Discarding {len (self .retryable_failed_files_info )} pending retryable file(s) due to cancellation.")
- self .cancellation_message_logged_this_session =False
- self .retryable_failed_files_info .clear ()
- self .favorite_download_queue .clear ()
- self .permanently_failed_files_for_dialog .clear ()
- self .is_processing_favorites_queue =False
- self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
- self ._update_favorite_scope_button_text ()
- if hasattr (self ,'link_input'):
- self .last_link_input_text_for_queue_sync =self .link_input .text ()
- self .cancellation_message_logged_this_session =False
-
- def _get_domain_for_service (self ,service_name :str )->str :
- """Determines the base domain for a given service."""
- if not isinstance (service_name ,str ):
- return "kemono.su"
- service_lower =service_name .lower ()
- coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'}
- if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']:
- return "coomer.su"
- return "kemono.su"
-
- 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 =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 =[]
-
- if not cancelled_by_user and not self.retryable_failed_files_info:
- self._clear_session_file()
- self.interrupted_session_data = None
- self.is_restore_pending = False
-
- self ._finalize_download_history ()
- status_message =self ._tr ("status_cancelled_by_user","Cancelled by user")if cancelled_by_user else self ._tr ("status_completed","Completed")
- if cancelled_by_user and self .retryable_failed_files_info :
- self .log_signal .emit (f" Download cancelled, discarding {len (self .retryable_failed_files_info )} file(s) that were pending retry.")
- self .retryable_failed_files_info .clear ()
-
- 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 )
-
- if kept_original_names_list :
- intro_msg =(
- HTML_PREFIX +
- "ℹ️ The following files from multi-file manga posts "
- "(after the first file) kept their original names:
"
- )
- self .log_signal .emit (intro_msg )
-
- html_list_items =""
- for name in kept_original_names_list :
- html_list_items +=f"- {name }
"
- html_list_items +="
"
-
- self .log_signal .emit (HTML_PREFIX +html_list_items )
- self .log_signal .emit ("="*40 )
-
- if self .download_thread :
- try :
- if hasattr (self .download_thread ,'progress_signal'):self .download_thread .progress_signal .disconnect (self .handle_main_log )
- if hasattr (self .download_thread ,'add_character_prompt_signal'):self .download_thread .add_character_prompt_signal .disconnect (self .add_character_prompt_signal )
- if hasattr (self .download_thread ,'finished_signal'):self .download_thread .finished_signal .disconnect (self .download_finished )
- 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 )
- if hasattr (self .download_thread ,'missed_character_post_signal'):
- self .download_thread .missed_character_post_signal .disconnect (self .handle_missed_character_post )
- if hasattr (self .download_thread ,'retryable_file_failed_signal'):
- self .download_thread .retryable_file_failed_signal .disconnect (self ._handle_retryable_file_failure )
- if hasattr (self .download_thread ,'file_successfully_downloaded_signal'):
- self .download_thread .file_successfully_downloaded_signal .disconnect (self ._handle_actual_file_downloaded )
- if hasattr (self .download_thread ,'post_processed_for_history_signal'):
- self .download_thread .post_processed_for_history_signal .disconnect (self ._add_to_history_candidates )
- except (TypeError ,RuntimeError )as e :
- self .log_signal .emit (f"ℹ️ Note during single-thread signal disconnection: {e }")
-
- if not self .download_thread .isRunning ():
-
- if self .download_thread :
- self .download_thread .deleteLater ()
- self .download_thread =None
-
- self .progress_label .setText (
- f"{status_message }: "
- f"{total_downloaded } {self ._tr ('files_downloaded_label','downloaded')}, "
- f"{total_skipped } {self ._tr ('files_skipped_label','skipped')}."
- )
- self .file_progress_label .setText ("")
- if not cancelled_by_user :self ._try_process_next_external_link ()
-
- 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
-
- self .active_futures =[]
- if self .pause_event :self .pause_event .clear ()
- self .cancel_btn .setEnabled (False )
- self .is_paused =False
- if not cancelled_by_user and self .retryable_failed_files_info :
- num_failed =len (self .retryable_failed_files_info )
- reply =QMessageBox .question (self ,"Retry Failed Downloads?",
- f"{num_failed } file(s) failed with potentially recoverable errors (e.g., IncompleteRead).\n\n"
- "Would you like to attempt to download these failed files again?",
- QMessageBox .Yes |QMessageBox .No ,QMessageBox .Yes )
- if reply ==QMessageBox .Yes :
- self ._start_failed_files_retry_session ()
- return
- else :
- self .log_signal .emit ("ℹ️ User chose not to retry failed files.")
- self .permanently_failed_files_for_dialog .extend (self .retryable_failed_files_info )
- if self .permanently_failed_files_for_dialog :
- self .log_signal .emit (f"🆘 Error button enabled. {len (self .permanently_failed_files_for_dialog )} file(s) can be viewed.")
- self .cancellation_message_logged_this_session =False
- self .retryable_failed_files_info .clear ()
-
- self .is_fetcher_thread_running =False
-
- if self .is_processing_favorites_queue :
- if not self .favorite_download_queue :
- self .is_processing_favorites_queue =False
- self .log_signal .emit (f"✅ All {self .current_processing_favorite_item_info .get ('type','item')} downloads from favorite queue have been processed.")
- self .set_ui_enabled (not self ._is_download_active ())
- else :
- self ._process_next_favorite_download ()
- else :
- self .set_ui_enabled (True )
- self .cancellation_message_logged_this_session =False
-
- def _handle_thumbnail_mode_change (self ,thumbnails_checked ):
- """Handles UI changes when 'Download Thumbnails Only' is toggled."""
- if not hasattr (self ,'scan_content_images_checkbox'):
- return
-
- if thumbnails_checked :
- self .scan_content_images_checkbox .setChecked (True )
- self .scan_content_images_checkbox .setEnabled (False )
- self .scan_content_images_checkbox .setToolTip (
- "Automatically enabled and locked because 'Download Thumbnails Only' is active.\n"
- "In this mode, only images found by content scanning will be downloaded."
- )
- else :
- self .scan_content_images_checkbox .setEnabled (True )
- self .scan_content_images_checkbox .setChecked (False )
- self .scan_content_images_checkbox .setToolTip (self ._original_scan_content_tooltip )
-
- def _start_failed_files_retry_session (self ,files_to_retry_list =None ):
- if files_to_retry_list :
- self .files_for_current_retry_session =list (files_to_retry_list )
- self .permanently_failed_files_for_dialog =[f for f in self .permanently_failed_files_for_dialog if f not in files_to_retry_list ]
- else :
- self .files_for_current_retry_session =list (self .retryable_failed_files_info )
- self .retryable_failed_files_info .clear ()
- self .log_signal .emit (f"🔄 Starting retry session for {len (self .files_for_current_retry_session )} file(s)...")
- self .set_ui_enabled (False )
- if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_retry_button_text","❌ Cancel Retry"))
-
-
- self .active_retry_futures =[]
- self .processed_retry_count =0
- self .succeeded_retry_count =0
- self .failed_retry_count_in_session =0
- self .total_files_for_retry =len (self .files_for_current_retry_session )
- self .active_retry_futures_map ={}
-
- self .progress_label .setText (self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =0 ,total_posts =self .total_files_for_retry ,progress_percent =0.0 ).replace ("posts","files"))
- self .cancellation_event .clear ()
-
- num_retry_threads =1
- try :
- num_threads_from_gui =int (self .thread_count_input .text ().strip ())
- num_retry_threads =max (1 ,min (num_threads_from_gui ,MAX_FILE_THREADS_PER_POST_OR_WORKER ,self .total_files_for_retry if self .total_files_for_retry >0 else 1 ))
- except ValueError :
- num_retry_threads =1
-
- self .retry_thread_pool =ThreadPoolExecutor (max_workers =num_retry_threads ,thread_name_prefix ='RetryFile_')
- common_ppw_args_for_retry ={
- 'download_root':self .dir_input .text ().strip (),
- 'known_names':list (KNOWN_NAMES ),
- 'emitter':self .worker_to_gui_queue ,
- 'unwanted_keywords':{'spicy','hd','nsfw','4k','preview','teaser','clip'},
- 'filter_mode':self .get_filter_mode (),
- 'skip_zip':self .skip_zip_checkbox .isChecked (),
- 'skip_rar':self .skip_rar_checkbox .isChecked (),
- 'use_subfolders':self .use_subfolders_checkbox .isChecked (),
- 'use_post_subfolders':self .use_subfolder_per_post_checkbox .isChecked (),
- 'compress_images':self .compress_images_checkbox .isChecked (),
- 'download_thumbnails':self .download_thumbnails_checkbox .isChecked (),
- '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 ,
- '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 (),
- '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 ()]if hasattr (self ,'remove_from_filename_input')else [],
- 'allow_multipart_download':self .allow_multipart_download_setting ,
- 'filter_character_list':None ,
- 'dynamic_character_filter_holder':None ,
- 'target_post_id_from_initial_url':None ,
- 'custom_folder_name':None ,
- 'num_file_threads':1 ,
- 'manga_date_file_counter_ref':None ,
- }
-
- for job_details in self .files_for_current_retry_session :
- future =self .retry_thread_pool .submit (self ._execute_single_file_retry ,job_details ,common_ppw_args_for_retry )
- future .add_done_callback (self ._handle_retry_future_result )
- self .active_retry_futures_map [future ]=job_details
- self .active_retry_futures .append (future )
-
- def _execute_single_file_retry (self ,job_details ,common_args ):
- """Executes a single file download retry attempt."""
- dummy_post_data ={'id':job_details ['original_post_id_for_log'],'title':job_details ['post_title']}
-
- ppw_init_args ={
- **common_args ,
- 'post_data':dummy_post_data ,
- 'service':job_details .get ('service','unknown_service'),
- 'user_id':job_details .get ('user_id','unknown_user'),
- 'api_url_input':job_details .get ('api_url_input',''),
- 'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ),
- 'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ),
- 'scan_content_for_images':common_args .get ('scan_content_for_images',False ),
- 'use_cookie':common_args .get ('use_cookie',False ),
- 'cookie_text':common_args .get ('cookie_text',""),
- 'selected_cookie_file':common_args .get ('selected_cookie_file',None ),
- 'app_base_dir':common_args .get ('app_base_dir',None ),
- }
- worker =PostProcessorWorker (**ppw_init_args )
-
- dl_count ,skip_count ,filename_saved ,original_kept ,status ,_ =worker ._download_single_file (
- file_info =job_details ['file_info'],
- target_folder_path =job_details ['target_folder_path'],
- headers =job_details ['headers'],
- original_post_id_for_log =job_details ['original_post_id_for_log'],
- skip_event =None ,
- post_title =job_details ['post_title'],
- file_index_in_post =job_details ['file_index_in_post'],
- num_files_in_this_post =job_details ['num_files_in_this_post'],
- forced_filename_override =job_details .get ('forced_filename_override')
- )
-
-
-
- is_successful_download =(status ==FILE_DOWNLOAD_STATUS_SUCCESS )
- is_resolved_as_skipped =(status ==FILE_DOWNLOAD_STATUS_SKIPPED )
-
- return is_successful_download or is_resolved_as_skipped
-
- def _handle_retry_future_result (self ,future ):
- self .processed_retry_count +=1
- was_successful =False
- try :
- if future .cancelled ():
- self .log_signal .emit (" A retry task was cancelled.")
- elif future .exception ():
- self .log_signal .emit (f"❌ Retry task worker error: {future .exception ()}")
- else :
- was_successful =future .result ()
- job_details =self .active_retry_futures_map .pop (future ,None )
- if was_successful :
- self .succeeded_retry_count +=1
- else :
- self .failed_retry_count_in_session +=1
- if job_details :
- self .permanently_failed_files_for_dialog .append (job_details )
- except Exception as e :
- self .log_signal .emit (f"❌ Error in _handle_retry_future_result: {e }")
- self .failed_retry_count_in_session +=1
-
- progress_percent_retry =(self .processed_retry_count /self .total_files_for_retry *100 )if self .total_files_for_retry >0 else 0
- self .progress_label .setText (
- self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =self .processed_retry_count ,total_posts =self .total_files_for_retry ,progress_percent =progress_percent_retry ).replace ("posts","files")+
- f" ({self ._tr ('succeeded_text','Succeeded')}: {self .succeeded_retry_count }, {self ._tr ('failed_text','Failed')}: {self .failed_retry_count_in_session })"
- )
-
- if self .processed_retry_count >=self .total_files_for_retry :
- if all (f .done ()for f in self .active_retry_futures ):
- QTimer .singleShot (0 ,self ._retry_session_finished )
-
-
- def _retry_session_finished (self ):
- self .log_signal .emit ("🏁 Retry session finished.")
- self .log_signal .emit (f" Summary: {self .succeeded_retry_count } Succeeded, {self .failed_retry_count_in_session } Failed.")
-
- if self .retry_thread_pool :
- self .retry_thread_pool .shutdown (wait =True )
- self .retry_thread_pool =None
-
- if self .external_link_download_thread and not self .external_link_download_thread .isRunning ():
- self .external_link_download_thread .deleteLater ()
- self .external_link_download_thread =None
-
- self .active_retry_futures .clear ()
- self .active_retry_futures_map .clear ()
- self .files_for_current_retry_session .clear ()
-
- if self .permanently_failed_files_for_dialog :
- self .log_signal .emit (f"🆘 {self ._tr ('error_button_text','Error')} button enabled. {len (self .permanently_failed_files_for_dialog )} file(s) ultimately failed and can be viewed.")
-
- self .set_ui_enabled (not self ._is_download_active ())
- if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_button_text","❌ Cancel & Reset UI"))
- self .progress_label .setText (
- f"{self ._tr ('retry_finished_text','Retry Finished')}. "
- f"{self ._tr ('succeeded_text','Succeeded')}: {self .succeeded_retry_count }, "
- f"{self ._tr ('failed_text','Failed')}: {self .failed_retry_count_in_session }. "
- f"{self ._tr ('ready_for_new_task_text','Ready for new task.')}")
- self .file_progress_label .setText ("")
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
-
- def toggle_active_log_view (self ):
- if self .current_log_view =='progress':
- self .current_log_view ='missed_character'
- if self .log_view_stack :self .log_view_stack .setCurrentIndex (1 )
- if self .log_verbosity_toggle_button :
- self .log_verbosity_toggle_button .setText (self .CLOSED_EYE_ICON )
- self .log_verbosity_toggle_button .setToolTip ("Current View: Missed Character Log. Click to switch to Progress Log.")
- if self .progress_log_label :self .progress_log_label .setText (self ._tr ("missed_character_log_label_text","🚫 Missed Character Log:"))
- else :
- self .current_log_view ='progress'
- if self .log_view_stack :self .log_view_stack .setCurrentIndex (0 )
- if self .log_verbosity_toggle_button :
- self .log_verbosity_toggle_button .setText (self .EYE_ICON )
- self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.")
- 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 ():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 .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 )
- if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
- if self .log_verbosity_toggle_button :
- self .log_verbosity_toggle_button .setText (self .EYE_ICON )
- self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.")
-
- 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 (self ._tr ("progress_idle_text","Progress: Idle"));self .file_progress_label .setText ("")
- with self .downloaded_files_lock :count =len (self .downloaded_files );self .downloaded_files .clear ();
- self .missed_title_key_terms_count .clear ()
- self .missed_title_key_terms_examples .clear ()
- self .logged_summary_for_key_term .clear ()
- self .already_logged_bold_key_terms .clear ()
- self .missed_key_terms_buffer .clear ()
- self .favorite_download_queue .clear ()
- self .only_links_log_display_mode =LOG_DISPLAY_LINKS
- self .mega_download_log_preserved_once =False
- self .permanently_failed_files_for_dialog .clear ()
- self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
- self ._update_favorite_scope_button_text ()
- self .retryable_failed_files_info .clear ()
- self .cancellation_message_logged_this_session =False
- self .is_processing_favorites_queue =False
-
- 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 ()
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
- self .manga_filename_style =STYLE_POST_TITLE
- self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
-
- self .skip_words_scope =SKIP_SCOPE_POSTS
- self .settings .setValue (SKIP_WORDS_SCOPE_KEY ,self .skip_words_scope )
- self ._update_skip_scope_button_text ()
-
- self .char_filter_scope =CHAR_SCOPE_TITLE
- self ._update_char_filter_scope_button_text ()
-
- 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 )
-
- def _reset_ui_to_defaults (self ):
- 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 ();
- 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 );
- if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False )
- self .external_links_checkbox .setChecked (False )
- if self .manga_mode_checkbox :self .manga_mode_checkbox .setChecked (False )
- if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setChecked (False )
- self .selected_cookie_filepath =None
-
- if hasattr (self ,'cookie_text_input'):self .cookie_text_input .clear ()
- self .missed_title_key_terms_count .clear ()
- self .missed_title_key_terms_examples .clear ()
- self .logged_summary_for_key_term .clear ()
- self .already_logged_bold_key_terms .clear ()
- if hasattr (self ,'manga_date_prefix_input'):self .manga_date_prefix_input .clear ()
- if self .pause_event :self .pause_event .clear ()
- self .is_paused =False
- self .missed_key_terms_buffer .clear ()
- if self .download_extracted_links_button :
- self .only_links_log_display_mode =LOG_DISPLAY_LINKS
- self .cancellation_message_logged_this_session =False
- self .mega_download_log_preserved_once =False
- self .download_extracted_links_button .setEnabled (False )
-
- if self .missed_character_log_output :self .missed_character_log_output .clear ()
-
- self .permanently_failed_files_for_dialog .clear ()
- self .allow_multipart_download_setting =False
- self ._update_multipart_toggle_button_text ()
-
- 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 ()
-
- self .current_log_view ='progress'
- self ._update_cookie_input_visibility (False );self ._update_cookie_input_placeholders_and_tooltips ()
- if self .log_view_stack :self .log_view_stack .setCurrentIndex (0 )
- if self .progress_log_label :self .progress_log_label .setText ("📜 Progress Log:")
- if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
- self ._handle_filter_mode_change (self .radio_all ,True )
- self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ())
- self .filter_character_list ("")
-
- self .download_btn .setEnabled (True );self .cancel_btn .setEnabled (False )
- if self .reset_button :self .reset_button .setEnabled (True );self .reset_button .setText (self ._tr ("reset_button_text","🔄 Reset"));self .reset_button .setToolTip (self ._tr ("reset_button_tooltip","Reset all inputs and logs to default state (only when idle)."))
- if self .log_verbosity_toggle_button :
- self .log_verbosity_toggle_button .setText (self .EYE_ICON )
- self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.")
- self ._update_manga_filename_style_button_text ()
- self .update_ui_for_manga_mode (False )
- if hasattr (self ,'favorite_mode_checkbox'):
- self ._handle_favorite_mode_toggle (False )
- if hasattr (self ,'scan_content_images_checkbox'):
- self .scan_content_images_checkbox .setChecked (False )
- if hasattr (self ,'download_thumbnails_checkbox'):
- self ._handle_thumbnail_mode_change (self .download_thumbnails_checkbox .isChecked ())
-
- def _show_feature_guide (self ):
- steps_content_keys =[
- ("help_guide_step1_title","help_guide_step1_content"),
- ("help_guide_step2_title","help_guide_step2_content"),
- ("help_guide_step3_title","help_guide_step3_content"),
- ("help_guide_step4_title","help_guide_step4_content"),
- ("help_guide_step5_title","help_guide_step5_content"),
- ("help_guide_step6_title","help_guide_step6_content"),
- ("help_guide_step7_title","help_guide_step7_content"),
- ("help_guide_step8_title","help_guide_step8_content"),
- ("help_guide_step9_title","help_guide_step9_content"),
- ("column_header_post_title","Post Title"),
- ("column_header_date_uploaded","Date Uploaded"),
- ]
-
- steps =[
- ]
- for title_key ,content_key in steps_content_keys :
- title =self ._tr (title_key ,title_key )
- content =self ._tr (content_key ,f"Content for {content_key } not found.")
- steps .append ((title ,content ))
-
- guide_dialog =HelpGuideDialog (steps ,self )
- guide_dialog .exec_ ()
-
- def prompt_add_character (self ,character_name ):
- global KNOWN_NAMES
- 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 )
- result =(reply ==QMessageBox .Yes )
- if result :
- if self .add_new_character (name_to_add =character_name ,
- is_group_to_add =False ,
- aliases_to_add =[character_name ],
- suppress_similarity_prompt =False ):
- 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, failed, or a similar name conflict was not overridden.")
- self .character_prompt_response_signal .emit (result )
-
- def receive_add_character_result (self ,result ):
- with QMutexLocker (self .prompt_mutex ):self ._add_character_response =result
- 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'}")
-
- def _update_multipart_toggle_button_text (self ):
- if hasattr (self ,'multipart_toggle_button'):
- if self .allow_multipart_download_setting :
- self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON"))
- self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON"))
- else :
- self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF"))
- self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF"))
-
- def _toggle_multipart_mode (self ):
- 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 (
- "Multi-part download advisory:
"
- ""
- "- Best suited for large files (e.g., single post videos).
"
- "- When downloading a full creator feed with many small files (like images):"
- "
- May not offer significant speed benefits.
"
- "- Could potentially make the UI feel choppy.
"
- "- May spam the process log with rapid, numerous small download messages.
"
- "- Consider using the 'Videos' filter if downloading a creator feed to primarily target large files for multi-part.
"
- "
"
- "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 )
- msg_box .exec_ ()
-
- if msg_box .clickedButton ()==cancel_button :
- self .log_signal .emit ("ℹ️ Multi-part download enabling cancelled by user.")
- return
-
- self .allow_multipart_download_setting =not self .allow_multipart_download_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 _open_known_txt_file (self ):
- if not os .path .exists (self .config_file ):
- QMessageBox .warning (self ,"File Not Found",
- f"The file 'Known.txt' was not found at:\n{self .config_file }\n\n"
- "It will be created automatically when you add a known name or close the application.")
- self .log_signal .emit (f"ℹ️ 'Known.txt' not found at {self .config_file }. It will be created later.")
- return
-
- try :
- if sys .platform =="win32":
- os .startfile (self .config_file )
- elif sys .platform =="darwin":
- subprocess .call (['open',self .config_file ])
- else :
- subprocess .call (['xdg-open',self .config_file ])
- self .log_signal .emit (f"ℹ️ Attempted to open '{os .path .basename (self .config_file )}' with the default editor.")
- except FileNotFoundError :
- QMessageBox .critical (self ,"Error",f"Could not find '{os .path .basename (self .config_file )}' at {self .config_file } to open it.")
- self .log_signal .emit (f"❌ Error: '{os .path .basename (self .config_file )}' not found at {self .config_file } when trying to open.")
- except Exception as e :
- QMessageBox .critical (self ,"Error Opening File",f"Could not open '{os .path .basename (self .config_file )}':\n{e }")
- self .log_signal .emit (f"❌ Error opening '{os .path .basename (self .config_file )}': {e }")
-
- def _show_add_to_filter_dialog (self ):
- global KNOWN_NAMES
- if not KNOWN_NAMES :
- QMessageBox .information (self ,"No Known Names","Your 'Known.txt' list is empty. Add some names first.")
- return
-
- dialog =KnownNamesFilterDialog (KNOWN_NAMES ,self ,self )
- if dialog .exec_ ()==QDialog .Accepted :
- selected_entries =dialog .get_selected_entries ()
- if selected_entries :
- self ._add_names_to_character_filter_input (selected_entries )
-
- def _add_names_to_character_filter_input (self ,selected_entries ):
- """
- Adds the selected known name entries to the character filter input field.
- """
- if not selected_entries :
- return
-
- names_to_add_str_list =[]
- for entry in selected_entries :
- if entry .get ("is_group"):
- aliases_str =", ".join (entry .get ("aliases",[]))
- names_to_add_str_list .append (f"({aliases_str })~")
- else :
- names_to_add_str_list .append (entry .get ("name",""))
-
- names_to_add_str_list =[s for s in names_to_add_str_list if s ]
-
- if not names_to_add_str_list :
- return
-
- current_filter_text =self .character_input .text ().strip ()
- new_text_to_append =", ".join (names_to_add_str_list )
-
- self .character_input .setText (f"{current_filter_text }, {new_text_to_append }"if current_filter_text else new_text_to_append )
- self .log_signal .emit (f"ℹ️ Added to character filter: {new_text_to_append }")
-
- def _update_favorite_scope_button_text (self ):
- if not hasattr (self ,'favorite_scope_toggle_button')or not self .favorite_scope_toggle_button :
- return
- if self .favorite_download_scope ==FAVORITE_SCOPE_SELECTED_LOCATION :
- self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_selected_location_text","Scope: Selected Location"))
-
- elif self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS :
- self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_artist_folders_text","Scope: Artist Folders"))
-
- else :
- self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_unknown_text","Scope: Unknown"))
-
-
- def _cycle_favorite_scope (self ):
- if self .favorite_download_scope ==FAVORITE_SCOPE_SELECTED_LOCATION :
- self .favorite_download_scope =FAVORITE_SCOPE_ARTIST_FOLDERS
- else :
- self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
- self ._update_favorite_scope_button_text ()
- self .log_signal .emit (f"ℹ️ Favorite download scope changed to: '{self .favorite_download_scope }'")
-
- def _show_empty_popup (self ):
- """Creates and shows the empty popup dialog."""
- if self.is_restore_pending:
- QMessageBox.information(self, self._tr("restore_pending_title", "Restore Pending"),
- self._tr("restore_pending_message_creator_selection",
- "Please 'Restore Download' or 'Discard Session' before selecting new creators."))
- return
-
- dialog =EmptyPopupDialog (self .app_base_dir ,self ,self )
- if dialog .exec_ ()==QDialog .Accepted :
- if hasattr (dialog ,'selected_creators_for_queue')and dialog .selected_creators_for_queue :
- self .favorite_download_queue .clear ()
-
- for creator_data in dialog .selected_creators_for_queue :
- service =creator_data .get ('service')
- creator_id =creator_data .get ('id')
- creator_name =creator_data .get ('name','Unknown Creator')
- domain =dialog ._get_domain_for_service (service )
-
- if service and creator_id :
- url =f"https://{domain }/{service }/user/{creator_id }"
- queue_item ={
- 'url':url ,
- 'name':creator_name ,
- 'name_for_folder':creator_name ,
- 'type':'creator_popup_selection',
- 'scope_from_popup':dialog .current_scope_mode
- }
- self .favorite_download_queue .append (queue_item )
-
- if self .favorite_download_queue :
- self .log_signal .emit (f"ℹ️ {len (self .favorite_download_queue )} creators added to download queue from popup. Click 'Start Download' to process.")
- if hasattr (self ,'link_input'):
- self .last_link_input_text_for_queue_sync =self .link_input .text ()
-
- def _show_favorite_artists_dialog (self ):
- if self ._is_download_active ()or self .is_processing_favorites_queue :
- QMessageBox .warning (self ,"Busy","Another download operation is already in progress.")
- return
-
- cookies_config ={
- 'use_cookie':self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ,
- 'cookie_text':self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "",
- 'selected_cookie_file':self .selected_cookie_filepath ,
- 'app_base_dir':self .app_base_dir
- }
-
- dialog =FavoriteArtistsDialog (self ,cookies_config )
- if dialog .exec_ ()==QDialog .Accepted :
- selected_artists =dialog .get_selected_artists ()
- if selected_artists :
- if len (selected_artists )>1 and self .link_input :
- display_names =", ".join ([artist ['name']for artist in selected_artists ])
- if self .link_input :
- self .link_input .clear ()
- self .link_input .setPlaceholderText (f"{len (selected_artists )} favorite artists selected for download queue.")
- self .log_signal .emit (f"ℹ️ Multiple favorite artists selected. Displaying names: {display_names }")
- elif len (selected_artists )==1 :
- self .link_input .setText (selected_artists [0 ]['url'])
- self .log_signal .emit (f"ℹ️ Single favorite artist selected: {selected_artists [0 ]['name']}")
-
- self .log_signal .emit (f"ℹ️ Queuing {len (selected_artists )} favorite artist(s) for download.")
- for artist_data in selected_artists :
- self .favorite_download_queue .append ({'url':artist_data ['url'],'name':artist_data ['name'],'name_for_folder':artist_data ['name'],'type':'artist'})
-
- if not self .is_processing_favorites_queue :
- self ._process_next_favorite_download ()
- else :
- self .log_signal .emit ("ℹ️ No favorite artists were selected for download.")
- QMessageBox .information (self ,
- self ._tr ("fav_artists_no_selection_title","No Selection"),
- self ._tr ("fav_artists_no_selection_message","Please select at least one artist to download."))
- else :
- self .log_signal .emit ("ℹ️ Favorite artists selection cancelled.")
-
- def _show_favorite_posts_dialog (self ):
- if self ._is_download_active ()or self .is_processing_favorites_queue :
- QMessageBox .warning (self ,"Busy","Another download operation is already in progress.")
- return
-
- cookies_config ={
- 'use_cookie':self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ,
- 'cookie_text':self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "",
- 'selected_cookie_file':self .selected_cookie_filepath ,
- 'app_base_dir':self .app_base_dir
- }
- global KNOWN_NAMES
-
- target_domain_preference_for_fetch =None
-
- if cookies_config ['use_cookie']:
- self .log_signal .emit ("Favorite Posts: 'Use Cookie' is checked. Determining target domain...")
- kemono_cookies =prepare_cookies_for_request (
- cookies_config ['use_cookie'],
- cookies_config ['cookie_text'],
- cookies_config ['selected_cookie_file'],
- cookies_config ['app_base_dir'],
- lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Kemono] {msg }"),
- target_domain ="kemono.su"
- )
- coomer_cookies =prepare_cookies_for_request (
- cookies_config ['use_cookie'],
- cookies_config ['cookie_text'],
- cookies_config ['selected_cookie_file'],
- cookies_config ['app_base_dir'],
- lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Coomer] {msg }"),
- target_domain ="coomer.su"
- )
-
- kemono_ok =bool (kemono_cookies )
- coomer_ok =bool (coomer_cookies )
-
- if kemono_ok and not coomer_ok :
- target_domain_preference_for_fetch ="kemono.su"
- self .log_signal .emit (" ↳ Only Kemono.su cookies loaded. Will fetch favorites from Kemono.su only.")
- elif coomer_ok and not kemono_ok :
- target_domain_preference_for_fetch ="coomer.su"
- self .log_signal .emit (" ↳ Only Coomer.su cookies loaded. Will fetch favorites from Coomer.su only.")
- elif kemono_ok and coomer_ok :
- target_domain_preference_for_fetch =None
- self .log_signal .emit (" ↳ Cookies for both Kemono.su and Coomer.su loaded. Will attempt to fetch from both.")
- else :
- self .log_signal .emit (" ↳ No valid cookies loaded for Kemono.su or Coomer.su.")
- cookie_help_dialog =CookieHelpDialog (self ,self )
- cookie_help_dialog .exec_ ()
- return
- else :
- self .log_signal .emit ("Favorite Posts: 'Use Cookie' is NOT checked. Cookies are required.")
- cookie_help_dialog =CookieHelpDialog (self ,self )
- cookie_help_dialog .exec_ ()
- return
-
- dialog =FavoritePostsDialog (self ,cookies_config ,KNOWN_NAMES ,target_domain_preference_for_fetch )
- if dialog .exec_ ()==QDialog .Accepted :
- selected_posts =dialog .get_selected_posts ()
- if selected_posts :
- self .log_signal .emit (f"ℹ️ Queuing {len (selected_posts )} favorite post(s) for download.")
- for post_data in selected_posts :
- domain =self ._get_domain_for_service (post_data ['service'])
- direct_post_url =f"https://{domain }/{post_data ['service']}/user/{str (post_data ['creator_id'])}/post/{str (post_data ['post_id'])}"
-
- queue_item ={
- 'url':direct_post_url ,
- 'name':post_data ['title'],
- 'name_for_folder':post_data ['creator_name_resolved'],
- 'type':'post'
- }
- self .favorite_download_queue .append (queue_item )
-
- if not self .is_processing_favorites_queue :
- self ._process_next_favorite_download ()
- else :
- self .log_signal .emit ("ℹ️ No favorite posts were selected for download.")
- else :
- self .log_signal .emit ("ℹ️ Favorite posts selection cancelled.")
-
- def _process_next_favorite_download (self ):
- if self ._is_download_active ():
- self .log_signal .emit ("ℹ️ Waiting for current download to finish before starting next favorite.")
- return
- if not self .favorite_download_queue :
- if self .is_processing_favorites_queue :
- self .is_processing_favorites_queue =False
- item_type_log ="item"
- if hasattr (self ,'current_processing_favorite_item_info')and self .current_processing_favorite_item_info :
- item_type_log =self .current_processing_favorite_item_info .get ('type','item')
- self .log_signal .emit (f"✅ All {item_type_log } downloads from favorite queue have been processed.")
- self .set_ui_enabled (True )
- return
- if not self .is_processing_favorites_queue :
- self .is_processing_favorites_queue =True
- self .current_processing_favorite_item_info =self .favorite_download_queue .popleft ()
- next_url =self .current_processing_favorite_item_info ['url']
- item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item')
-
- item_type =self .current_processing_favorite_item_info .get ('type','artist')
- self .log_signal .emit (f"▶️ Processing next favorite from queue: '{item_display_name }' ({next_url })")
-
- override_dir =None
- item_scope =self .current_processing_favorite_item_info .get ('scope_from_popup')
- if item_scope is None :
- item_scope =self .favorite_download_scope
-
- main_download_dir =self .dir_input .text ().strip ()
-
-
-
-
- should_create_artist_folder =False
- if item_type =='creator_popup_selection'and item_scope ==EmptyPopupDialog .SCOPE_CREATORS :
- should_create_artist_folder =True
- elif item_type !='creator_popup_selection'and self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS :
- should_create_artist_folder =True
-
- if should_create_artist_folder and main_download_dir :
- folder_name_key =self .current_processing_favorite_item_info .get ('name_for_folder','Unknown_Folder')
- item_specific_folder_name =clean_folder_name (folder_name_key )
- override_dir =os .path .normpath (os .path .join (main_download_dir ,item_specific_folder_name ))
- self .log_signal .emit (f" Scope requires artist folder. Target directory: '{override_dir }'")
-
- success_starting_download =self .start_download (direct_api_url =next_url ,override_output_dir =override_dir )
-
- if not success_starting_download :
- self .log_signal .emit (f"⚠️ Failed to initiate download for '{item_display_name }'. Skipping this item in queue.")
- self .download_finished (total_downloaded =0 ,total_skipped =1 ,cancelled_by_user =True ,kept_original_names_list =[])
-
-if __name__ =='__main__':
- import traceback
- import sys
- import os
- import time
-
- def handle_uncaught_exception (exc_type ,exc_value ,exc_traceback ):
- """Handles uncaught exceptions by logging them to a file."""
-
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
-
- base_dir_for_log =sys ._MEIPASS
- else :
-
- base_dir_for_log =os .path .dirname (os .path .abspath (__file__ ))
-
- log_dir =os .path .join (base_dir_for_log ,"logs")
- log_file_path =os .path .join (log_dir ,"uncaught_exceptions.log")
-
- try :
- os .makedirs (os .path .dirname (log_file_path ),exist_ok =True )
- with open (log_file_path ,"a",encoding ="utf-8")as f :
- f .write (f"Timestamp: {time .strftime ('%Y-%m-%d %H:%M:%S')}\n")
- traceback .print_exception (exc_type ,exc_value ,exc_traceback ,file =f )
- f .write ("-"*80 +"\n\n")
- except Exception as log_ex :
-
- print (f"CRITICAL: Failed to write to uncaught_exceptions.log: {log_ex }",file =sys .stderr )
- traceback .print_exception (exc_type ,exc_value ,exc_traceback ,file =sys .stderr )
- sys .__excepthook__ (exc_type ,exc_value ,exc_traceback )
-
- sys .excepthook =handle_uncaught_exception
-
- try :
- qt_app =QApplication (sys .argv )
-
- QCoreApplication .setOrganizationName (CONFIG_ORGANIZATION_NAME )
- QCoreApplication .setApplicationName (CONFIG_APP_NAME_MAIN )
- if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
- base_dir =sys ._MEIPASS
- else :
- base_dir =os .path .dirname (os .path .abspath (__file__ ))
- icon_path =os .path .join (base_dir ,'assets','Kemono.ico')
- if os .path .exists (icon_path ):qt_app .setWindowIcon (QIcon (icon_path ))
- else :print (f"Warning: Application icon 'assets/Kemono.ico' not found at {icon_path }")
-
- downloader_app_instance =DownloaderApp ()
- primary_screen =QApplication .primaryScreen ()
- if not primary_screen :
- screens =QApplication .screens ()
- if not screens :
- downloader_app_instance .resize (1024 ,768 )
- downloader_app_instance .show ()
- sys .exit (qt_app .exec_ ())
- primary_screen =screens [0 ]
-
- available_geo =primary_screen .availableGeometry ()
- screen_width =available_geo .width ()
- screen_height =available_geo .height ()
- min_app_width =960
- min_app_height =680
- desired_app_width_ratio =0.80
- desired_app_height_ratio =0.85
-
- app_width =max (min_app_width ,int (screen_width *desired_app_width_ratio ))
- app_height =max (min_app_height ,int (screen_height *desired_app_height_ratio ))
- app_width =min (app_width ,screen_width )
- app_height =min (app_height ,screen_height )
-
- downloader_app_instance .resize (app_width ,app_height )
- downloader_app_instance .show ()
- downloader_app_instance ._center_on_screen ()
- try :
- tour_result =TourDialog .run_tour_if_needed (downloader_app_instance )
- if tour_result ==QDialog .Accepted :print ("Tour completed by user.")
- elif tour_result ==QDialog .Rejected :print ("Tour skipped or was already shown.")
- except NameError :
- print ("[Main] TourDialog class not found. Skipping tour.")
- except Exception as e_tour :
- print (f"[Main] Error during tour execution: {e_tour }")
-
- exit_code =qt_app .exec_ ()
- print (f"Application finished with exit code: {exit_code }")
- sys .exit (exit_code )
- except SystemExit :pass
- except Exception as e :
- print ("--- CRITICAL APPLICATION ERROR ---")
- print (f"An unhandled exception occurred: {e }")
- traceback .print_exc ()
- print ("--- END CRITICAL ERROR ---")
\ No newline at end of file
+ downloader_app_instance.resize(app_width, app_height)
+
+ # Show the main window and center it
+ downloader_app_instance.show()
+ if hasattr(downloader_app_instance, '_center_on_screen'):
+ downloader_app_instance._center_on_screen()
+
+ # --- First-Run Welcome Tour ---
+ # Check if the tour should be shown and run it.
+ # This static method call keeps the logic clean and contained.
+ if TourDialog.should_show_tour():
+ tour_dialog = TourDialog(parent_app=downloader_app_instance)
+ tour_dialog.exec_()
+
+ # --- Start Application ---
+ exit_code = qt_app.exec_()
+ print(f"Application finished with exit code: {exit_code}")
+ sys.exit(exit_code)
+
+ except SystemExit:
+ # Allow sys.exit() to work as intended
+ pass
+ except Exception as e:
+ print("--- CRITICAL APPLICATION STARTUP ERROR ---")
+ print(f"An unhandled exception occurred during application startup: {e}")
+ traceback.print_exc()
+ print("--- END CRITICAL ERROR ---")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/config/__init__.py b/src/config/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/config/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/config/constants.py b/src/config/constants.py
new file mode 100644
index 0000000..0386606
--- /dev/null
+++ b/src/config/constants.py
@@ -0,0 +1,110 @@
+# --- Application Metadata ---
+CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
+CONFIG_APP_NAME_MAIN = "ApplicationSettings"
+CONFIG_APP_NAME_TOUR = "ApplicationTour"
+
+# --- Filename and Folder Naming Styles ---
+STYLE_POST_TITLE = "post_title"
+STYLE_ORIGINAL_NAME = "original_name"
+STYLE_DATE_BASED = "date_based"
+STYLE_DATE_POST_TITLE = "date_post_title"
+STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
+MANGA_DATE_PREFIX_DEFAULT = ""
+
+# --- Download Scopes ---
+SKIP_SCOPE_FILES = "files"
+SKIP_SCOPE_POSTS = "posts"
+SKIP_SCOPE_BOTH = "both"
+
+CHAR_SCOPE_TITLE = "title"
+CHAR_SCOPE_FILES = "files"
+CHAR_SCOPE_BOTH = "both"
+CHAR_SCOPE_COMMENTS = "comments"
+
+FAVORITE_SCOPE_SELECTED_LOCATION = "selected_location"
+FAVORITE_SCOPE_ARTIST_FOLDERS = "artist_folders"
+
+# --- Download Status Constants ---
+FILE_DOWNLOAD_STATUS_SUCCESS = "success"
+FILE_DOWNLOAD_STATUS_SKIPPED = "skipped"
+FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later"
+FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION = "failed_permanent_session"
+
+# --- Threading and Performance ---
+MAX_THREADS = 200
+RECOMMENDED_MAX_THREADS = 50
+SOFT_WARNING_THREAD_THRESHOLD = 40
+MAX_FILE_THREADS_PER_POST_OR_WORKER = 10
+POST_WORKER_BATCH_THRESHOLD = 30
+POST_WORKER_NUM_BATCHES = 4
+POST_WORKER_BATCH_DELAY_SECONDS = 2.5
+MAX_POST_WORKERS_WHEN_COMMENT_FILTERING = 3
+
+# --- Multipart Download Settings ---
+MIN_SIZE_FOR_MULTIPART_DOWNLOAD = 10 * 1024 * 1024 # 10 MB
+MAX_PARTS_FOR_MULTIPART_DOWNLOAD = 15
+
+# --- UI and Settings Keys (for QSettings) ---
+TOUR_SHOWN_KEY = "neverShowTourAgainV19"
+MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
+SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1"
+ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
+USE_COOKIE_KEY = "useCookieV1"
+COOKIE_TEXT_KEY = "cookieTextV1"
+CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1"
+THEME_KEY = "currentThemeV2"
+SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1"
+LANGUAGE_KEY = "currentLanguageV1"
+DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
+
+# --- UI Constants and Identifiers ---
+HTML_PREFIX = ""
+LOG_DISPLAY_LINKS = "links"
+LOG_DISPLAY_DOWNLOAD_PROGRESS = "download_progress"
+
+# --- Dialog Return Codes ---
+CONFIRM_ADD_ALL_ACCEPTED = 1
+CONFIRM_ADD_ALL_SKIP_ADDING = 2
+CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
+
+# --- File Type Extensions ---
+IMAGE_EXTENSIONS = {
+ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
+ '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
+}
+VIDEO_EXTENSIONS = {
+ '.mp4', '.mov', '.mkv', '.webm', '.avi', '.wmv', '.flv', '.mpeg',
+ '.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob'
+}
+ARCHIVE_EXTENSIONS = {
+ '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'
+}
+AUDIO_EXTENSIONS = {
+ '.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
+ '.aiff', '.ape', '.mid', '.midi'
+}
+
+# --- Text Processing Constants ---
+MAX_FILENAME_COMPONENT_LENGTH = 150
+
+# Words to ignore when creating folder names from titles
+FOLDER_NAME_STOP_WORDS = {
+ "a", "alone", "am", "an", "and", "at", "be", "by", "com",
+ "for", "he", "her", "his", "i", "im", "in", "is", "it", "its",
+ "me", "my", "net", "not", "of", "on", "or", "org", "our",
+ "s", "she", "so", "the", "their", "they", "this",
+ "to", "ve", "was", "we", "were", "with", "www", "you", "your",
+}
+
+# Additional words to ignore specifically for creator-level downloads
+CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
+ "poll", "cover", "fan-art", "fanart", "requests", "request", "holiday",
+ "batch", "open", "closed", "winner", "loser", "wip",
+ "update", "news", "discussion", "question", "stream", "video", "sketchbook",
+ # Months and days
+ "jan", "january", "feb", "february", "mar", "march", "apr", "april",
+ "may", "jun", "june", "jul", "july", "aug", "august", "sep", "september",
+ "oct", "october", "nov", "november", "dec", "december",
+ "mon", "monday", "tue", "tuesday", "wed", "wednesday", "thu", "thursday",
+ "fri", "friday", "sat", "saturday", "sun", "sunday"
+}
diff --git a/src/core/__init__.py b/src/core/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/core/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/core/api_client.py b/src/core/api_client.py
new file mode 100644
index 0000000..9a37b5f
--- /dev/null
+++ b/src/core/api_client.py
@@ -0,0 +1,321 @@
+# --- Standard Library Imports ---
+import time
+import traceback
+from urllib.parse import urlparse
+
+# --- Third-Party Library Imports ---
+import requests
+
+# --- Local Application Imports ---
+from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
+from ..config.constants import (
+ STYLE_DATE_POST_TITLE
+)
+
+
+def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
+ """
+ Fetches a single page of posts from the API with retry logic.
+
+ Args:
+ api_url_base (str): The base URL for the user's posts.
+ headers (dict): The request headers.
+ offset (int): The offset for pagination.
+ logger (callable): Function to log messages.
+ cancellation_event (threading.Event): Event to signal cancellation.
+ pause_event (threading.Event): Event to signal pause.
+ cookies_dict (dict): A dictionary of cookies to include in the request.
+
+ Returns:
+ list: A list of post data dictionaries from the API.
+
+ Raises:
+ RuntimeError: If the fetch fails after all retries or encounters a non-retryable error.
+ """
+ if cancellation_event and cancellation_event.is_set():
+ logger(" Fetch cancelled before request.")
+ raise RuntimeError("Fetch operation cancelled by user.")
+ if pause_event and pause_event.is_set():
+ logger(" Post fetching paused...")
+ while pause_event.is_set():
+ if cancellation_event and cancellation_event.is_set():
+ logger(" Post fetching cancelled while paused.")
+ raise RuntimeError("Fetch operation cancelled by user.")
+ time.sleep(0.5)
+ logger(" Post fetching resumed.")
+
+ paginated_url = f'{api_url_base}?o={offset}'
+ max_retries = 3
+ retry_delay = 5
+
+ for attempt in range(max_retries):
+ if cancellation_event and cancellation_event.is_set():
+ raise RuntimeError("Fetch operation cancelled by user during retry loop.")
+
+ log_message = f" Fetching: {paginated_url} (Page approx. {offset // 50 + 1})"
+ if attempt > 0:
+ log_message += f" (Attempt {attempt + 1}/{max_retries})"
+ logger(log_message)
+
+ try:
+ response = requests.get(paginated_url, headers=headers, timeout=(15, 90), cookies=cookies_dict)
+ response.raise_for_status()
+
+ if 'application/json' not in response.headers.get('Content-Type', '').lower():
+ logger(f"⚠️ Unexpected content type from API: {response.headers.get('Content-Type')}. Body: {response.text[:200]}")
+ return []
+
+ return response.json()
+
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
+ logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
+ if attempt < max_retries - 1:
+ delay = retry_delay * (2 ** attempt)
+ logger(f" Retrying in {delay} seconds...")
+ time.sleep(delay)
+ continue
+ else:
+ logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
+ raise RuntimeError(f"Timeout or connection error fetching offset {offset}")
+ except requests.exceptions.RequestException as e:
+ err_msg = f"Error fetching offset {offset}: {e}"
+ if e.response is not None:
+ err_msg += f" (Status: {e.response.status_code}, Body: {e.response.text[:200]})"
+ raise RuntimeError(err_msg)
+ except ValueError as e: # JSON decode error
+ raise RuntimeError(f"Error decoding JSON from offset {offset}: {e}. Response: {response.text[:200]}")
+
+ raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
+
+
+def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
+ """Fetches all comments for a specific post."""
+ if cancellation_event and cancellation_event.is_set():
+ raise RuntimeError("Comment fetch operation cancelled by user.")
+
+ comments_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}/comments"
+ logger(f" Fetching comments: {comments_api_url}")
+
+ try:
+ response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict)
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.RequestException as e:
+ raise RuntimeError(f"Error fetching comments for post {post_id}: {e}")
+ except ValueError as e:
+ raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}")
+
+def download_from_api (
+api_url_input ,
+logger =print ,
+start_page =None ,
+end_page =None ,
+manga_mode =False ,
+cancellation_event =None ,
+pause_event =None ,
+use_cookie =False ,
+cookie_text ="",
+selected_cookie_file =None ,
+app_base_dir =None ,
+manga_filename_style_for_sort_check =None
+):
+ headers ={
+ 'User-Agent':'Mozilla/5.0',
+ 'Accept':'application/json'
+ }
+
+ service ,user_id ,target_post_id =extract_post_info (api_url_input )
+
+ if cancellation_event and cancellation_event .is_set ():
+ logger (" Download_from_api cancelled at start.")
+ return
+
+ parsed_input_url_for_domain =urlparse (api_url_input )
+ api_domain =parsed_input_url_for_domain .netloc
+ if not any (d in api_domain .lower ()for d in ['kemono.su','kemono.party','coomer.su','coomer.party']):
+ logger (f"⚠️ Unrecognized domain '{api_domain }' from input URL. Defaulting to kemono.su for API calls.")
+ api_domain ="kemono.su"
+ cookies_for_api =None
+ if use_cookie and app_base_dir :
+ cookies_for_api =prepare_cookies_for_request (use_cookie ,cookie_text ,selected_cookie_file ,app_base_dir ,logger ,target_domain =api_domain )
+ if target_post_id :
+ direct_post_api_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }/post/{target_post_id }"
+ logger (f" Attempting direct fetch for target post: {direct_post_api_url }")
+ try :
+ direct_response =requests .get (direct_post_api_url ,headers =headers ,timeout =(10 ,30 ),cookies =cookies_for_api )
+ direct_response .raise_for_status ()
+ direct_post_data =direct_response .json ()
+ if isinstance (direct_post_data ,list )and direct_post_data :
+ direct_post_data =direct_post_data [0 ]
+ if isinstance (direct_post_data ,dict )and 'post'in direct_post_data and isinstance (direct_post_data ['post'],dict ):
+ direct_post_data =direct_post_data ['post']
+ if isinstance (direct_post_data ,dict )and direct_post_data .get ('id')==target_post_id :
+ logger (f" ✅ Direct fetch successful for post {target_post_id }.")
+ yield [direct_post_data ]
+ return
+ else :
+ response_type =type (direct_post_data ).__name__
+ response_snippet =str (direct_post_data )[:200 ]
+ logger (f" ⚠️ Direct fetch for post {target_post_id } returned unexpected data (Type: {response_type }, Snippet: '{response_snippet }'). Falling back to pagination.")
+ except requests .exceptions .RequestException as e :
+ logger (f" ⚠️ Direct fetch failed for post {target_post_id }: {e }. Falling back to pagination.")
+ except Exception as e :
+ logger (f" ⚠️ Unexpected error during direct fetch for post {target_post_id }: {e }. Falling back to pagination.")
+ if not service or not user_id :
+ logger (f"❌ Invalid URL or could not extract service/user: {api_url_input }")
+ return
+ if target_post_id and (start_page or end_page ):
+ logger ("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
+
+ is_manga_mode_fetch_all_and_sort_oldest_first =manga_mode and (manga_filename_style_for_sort_check !=STYLE_DATE_POST_TITLE )and not target_post_id
+ api_base_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }"
+ page_size =50
+ if is_manga_mode_fetch_all_and_sort_oldest_first :
+ logger (f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
+ all_posts_for_manga_mode =[]
+ current_offset_manga =0
+ if start_page and start_page >1 :
+ current_offset_manga =(start_page -1 )*page_size
+ logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).")
+ elif start_page :
+ logger (f" Manga Mode: Starting fetch from page 1 (offset 0).")
+ if end_page :
+ logger (f" Manga Mode: Will fetch up to page {end_page }.")
+ while True :
+ if pause_event and pause_event .is_set ():
+ logger (" Manga mode post fetching paused...")
+ while pause_event .is_set ():
+ if cancellation_event and cancellation_event .is_set ():
+ logger (" Manga mode post fetching cancelled while paused.")
+ break
+ time .sleep (0.5 )
+ if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.")
+ if cancellation_event and cancellation_event .is_set ():
+ logger (" Manga mode post fetching cancelled.")
+ break
+ current_page_num_manga =(current_offset_manga //page_size )+1
+ if end_page and current_page_num_manga >end_page :
+ logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.")
+ break
+ try :
+ posts_batch_manga =fetch_posts_paginated (api_base_url ,headers ,current_offset_manga ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api )
+ if not isinstance (posts_batch_manga ,list ):
+ logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.")
+ break
+ if not posts_batch_manga :
+ logger ("✅ Reached end of posts (Manga Mode fetch all).")
+ if start_page and not end_page and current_page_num_manga 1 and not target_post_id :
+ current_offset =(start_page -1 )*page_size
+ current_page_num =start_page
+ logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).")
+ while True :
+ if pause_event and pause_event .is_set ():
+ logger (" Post fetching loop paused...")
+ while pause_event .is_set ():
+ if cancellation_event and cancellation_event .is_set ():
+ logger (" Post fetching loop cancelled while paused.")
+ break
+ time .sleep (0.5 )
+ if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.")
+ if cancellation_event and cancellation_event .is_set ():
+ logger (" Post fetching loop cancelled.")
+ break
+ if target_post_id and processed_target_post_flag :
+ break
+ if not target_post_id and end_page and current_page_num >end_page :
+ logger (f"✅ Reached specified end page ({end_page }) for creator feed. Stopping.")
+ break
+ try :
+ posts_batch =fetch_posts_paginated (api_base_url ,headers ,current_offset ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api )
+ if not isinstance (posts_batch ,list ):
+ logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).")
+ break
+ except RuntimeError as e :
+ if "cancelled by user"in str (e ).lower ():
+ logger (f"ℹ️ Pagination stopped due to cancellation: {e }")
+ else :
+ logger (f"❌ {e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).")
+ break
+ except Exception as e :
+ logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }")
+ traceback .print_exc ()
+ break
+ if not posts_batch :
+ if target_post_id and not processed_target_post_flag :
+ logger (f"❌ Target post {target_post_id } not found after checking all available pages (API returned no more posts at offset {current_offset }).")
+ elif not target_post_id :
+ if current_page_num ==(start_page or 1 ):
+ logger (f"😕 No posts found on the first page checked (page {current_page_num }, offset {current_offset }).")
+ else :
+ logger (f"✅ Reached end of posts (no more content from API at offset {current_offset }).")
+ break
+ if target_post_id and not processed_target_post_flag :
+ matching_post =next ((p for p in posts_batch if str (p .get ('id'))==str (target_post_id )),None )
+ if matching_post :
+ logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).")
+ yield [matching_post ]
+ processed_target_post_flag =True
+ elif not target_post_id :
+ yield posts_batch
+ if processed_target_post_flag :
+ break
+ current_offset +=page_size
+ current_page_num +=1
+ time .sleep (0.6 )
+ if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event .is_set ()):
+ logger (f"❌ Target post {target_post_id } could not be found after checking all relevant pages (final check after loop).")
\ No newline at end of file
diff --git a/src/core/manager.py b/src/core/manager.py
new file mode 100644
index 0000000..96e5170
--- /dev/null
+++ b/src/core/manager.py
@@ -0,0 +1,241 @@
+# --- Standard Library Imports ---
+import threading
+import time
+import os
+import json
+import traceback
+from concurrent.futures import ThreadPoolExecutor, as_completed, Future
+
+# --- Local Application Imports ---
+# These imports reflect the new, organized project structure.
+from .api_client import download_from_api
+from .workers import PostProcessorWorker, DownloadThread
+from ..config.constants import (
+ STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING,
+ MAX_THREADS, POST_WORKER_BATCH_THRESHOLD, POST_WORKER_NUM_BATCHES,
+ POST_WORKER_BATCH_DELAY_SECONDS
+)
+from ..utils.file_utils import clean_folder_name
+
+
+class DownloadManager:
+ """
+ Manages the entire download lifecycle, acting as a bridge between the UI
+ and the backend workers. It handles thread pools, task submission,
+ and state management for a download session.
+ """
+
+ def __init__(self, progress_queue):
+ """
+ Initializes the DownloadManager.
+
+ Args:
+ progress_queue (queue.Queue): A thread-safe queue for sending
+ status updates to the UI.
+ """
+ self.progress_queue = progress_queue
+ self.thread_pool = None
+ self.active_futures = []
+
+ # --- Session State ---
+ self.cancellation_event = threading.Event()
+ self.pause_event = threading.Event()
+ self.is_running = False
+
+ self.total_posts = 0
+ self.processed_posts = 0
+ self.total_downloads = 0
+ self.total_skips = 0
+ self.all_kept_original_filenames = []
+
+ def _log(self, message):
+ """Puts a progress message into the queue for the UI."""
+ self.progress_queue.put({'type': 'progress', 'payload': (message,)})
+
+ def start_session(self, config, restore_data=None):
+ """
+ Starts a new download session based on the provided configuration.
+ This is the main entry point called by the UI.
+
+ Args:
+ config (dict): A dictionary containing all settings from the UI.
+ restore_data (dict, optional): Data from a previous, interrupted session.
+ """
+ if self.is_running:
+ self._log("❌ Cannot start a new session: A session is already in progress.")
+ return
+
+ # --- Reset state for the new session ---
+ self.is_running = True
+ self.cancellation_event.clear()
+ self.pause_event.clear()
+ self.active_futures.clear()
+ self.total_posts = 0
+ self.processed_posts = 0
+ self.total_downloads = 0
+ self.total_skips = 0
+ self.all_kept_original_filenames = []
+
+ # --- Decide execution strategy (multi-threaded vs. single-threaded) ---
+ is_single_post = bool(config.get('target_post_id_from_initial_url'))
+ use_multithreading = config.get('use_multithreading', True)
+ 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:
+ # Start a separate thread to manage fetching and queuing to the thread pool
+ fetcher_thread = threading.Thread(
+ target=self._fetch_and_queue_posts_for_pool,
+ args=(config, restore_data),
+ daemon=True
+ )
+ fetcher_thread.start()
+ else:
+ # For single posts or sequential manga mode, use a single worker thread
+ # which is simpler and ensures order.
+ self._start_single_threaded_session(config)
+
+ def _start_single_threaded_session(self, config):
+ """Handles downloads that are best processed by a single worker thread."""
+ self._log("ℹ️ Initializing single-threaded download process...")
+
+ # The original DownloadThread is now a pure Python thread, not a QThread.
+ # We run its `run` method in a standard Python thread.
+ self.worker_thread = threading.Thread(
+ target=self._run_single_worker,
+ args=(config,),
+ daemon=True
+ )
+ self.worker_thread.start()
+
+ def _run_single_worker(self, config):
+ """Target function for the single-worker thread."""
+ try:
+ # Pass the queue directly to the worker for it to send updates
+ worker = DownloadThread(config, self.progress_queue)
+ worker.run() # This is the main blocking call for this thread
+ except Exception as e:
+ self._log(f"❌ CRITICAL ERROR in single-worker thread: {e}")
+ self._log(traceback.format_exc())
+ finally:
+ self.is_running = False
+
+ def _fetch_and_queue_posts_for_pool(self, config, restore_data):
+ """
+ Fetches all posts from the API and submits them as tasks to a thread pool.
+ This method runs in its own dedicated thread to avoid blocking.
+ """
+ try:
+ num_workers = min(config.get('num_threads', 4), MAX_THREADS)
+ self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
+
+ # Fetch posts
+ # In a real implementation, this would call `api_client.download_from_api`
+ if restore_data:
+ all_posts = restore_data['all_posts_data']
+ processed_ids = set(restore_data['processed_post_ids'])
+ posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids]
+ self.total_posts = len(all_posts)
+ self.processed_posts = len(processed_ids)
+ self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.")
+ else:
+ posts_to_process = self._get_all_posts(config)
+ self.total_posts = len(posts_to_process)
+ self.processed_posts = 0
+
+ self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
+
+ if not posts_to_process:
+ self._log("✅ No new posts to process.")
+ return
+
+ # Submit tasks to the pool
+ for post_data in posts_to_process:
+ if self.cancellation_event.is_set():
+ break
+ # Each PostProcessorWorker gets the queue to send its own updates
+ worker = PostProcessorWorker(post_data, config, self.progress_queue)
+ future = self.thread_pool.submit(worker.process)
+ future.add_done_callback(self._handle_future_result)
+ self.active_futures.append(future)
+
+ except Exception as e:
+ self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
+ self._log(traceback.format_exc())
+ finally:
+ # Wait for all submitted tasks to complete before shutting down
+ if self.thread_pool:
+ self.thread_pool.shutdown(wait=True)
+ self.is_running = False
+ self._log("🏁 All processing tasks have completed.")
+ # Emit final signal
+ self.progress_queue.put({
+ 'type': 'finished',
+ 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
+ })
+
+ def _get_all_posts(self, config):
+ """Helper to fetch all posts using the API client."""
+ all_posts = []
+ # This generator yields batches of posts
+ post_generator = download_from_api(
+ api_url_input=config['api_url'],
+ logger=self._log,
+ # ... pass other relevant config keys ...
+ cancellation_event=self.cancellation_event,
+ pause_event=self.pause_event
+ )
+ for batch in post_generator:
+ all_posts.extend(batch)
+ return all_posts
+
+ def _handle_future_result(self, future: Future):
+ """Callback executed when a worker task completes."""
+ if self.cancellation_event.is_set():
+ return
+
+ with threading.Lock(): # Protect shared counters
+ self.processed_posts += 1
+ try:
+ if future.cancelled():
+ self._log("⚠️ A post processing task was cancelled.")
+ self.total_skips += 1
+ else:
+ result = future.result()
+ # Unpack result tuple from the worker
+ (dl_count, skip_count, kept_originals,
+ retryable, permanent, history) = result
+ self.total_downloads += dl_count
+ self.total_skips += skip_count
+ self.all_kept_original_filenames.extend(kept_originals)
+
+ # Queue up results for UI to handle
+ if retryable:
+ self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
+ if permanent:
+ self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
+ if history:
+ self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
+
+ except Exception as e:
+ self._log(f"❌ Worker task resulted in an exception: {e}")
+ self.total_skips += 1 # Count errored posts as skipped
+
+ # Update overall progress
+ self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
+
+ def cancel_session(self):
+ """Cancels the current running session."""
+ if not self.is_running:
+ return
+ self._log("⚠️ Cancellation requested by user...")
+ self.cancellation_event.set()
+
+ # For single thread mode, the worker checks the event
+ # For multi-thread mode, shut down the pool
+ if self.thread_pool:
+ # Don't wait, just cancel pending futures and let the fetcher thread exit
+ self.thread_pool.shutdown(wait=False, cancel_futures=True)
+
+ self.is_running = False
diff --git a/downloader_utils.py b/src/core/workers.py
similarity index 71%
rename from downloader_utils.py
rename to src/core/workers.py
index 93f9c48..c5d054c 100644
--- a/downloader_utils.py
+++ b/src/core/workers.py
@@ -1,813 +1,46 @@
-import os
-import time
-import requests
-import re
-import threading
+# --- Standard Library Imports ---
+import os
+import queue
+import re
+import threading
+import time
+import traceback
+import uuid
+import http
+import html
import json
-import queue
-import hashlib
-import http .client
-import traceback
-from concurrent .futures import ThreadPoolExecutor ,Future ,CancelledError ,as_completed
-from collections import deque
-import html
-from PyQt5 .QtCore import QObject ,pyqtSignal ,QThread ,QMutex ,QMutexLocker
+from collections import deque
+import hashlib
+from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future
+from io import BytesIO
from urllib .parse import urlparse
-import uuid
-try :
- from mega import Mega
+import requests
+# --- Third-Party Library Imports ---
+try:
+ from PIL import Image
+except ImportError:
+ Image = None
+# --- PyQt5 Imports ---
+from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess
+# --- Local Application Imports ---
+from .api_client import download_from_api, fetch_post_comments
+from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE
+from ..services.drive_downloader import (
+ download_mega_file, download_gdrive_file, download_dropbox_file
+)
+# Corrected Imports:
+from ..utils.file_utils import (
+ is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES,
+ clean_filename, clean_folder_name
+)
+from ..utils.network_utils import prepare_cookies_for_request, get_link_platform
+from ..utils.text_utils import (
+ is_title_match_for_character, is_filename_match_for_character, strip_html_tags,
+ extract_folder_name_from_title, # This was the function causing the error
+ match_folders_from_title, match_folders_from_filename_enhanced
+)
+from ..config.constants import *
-
- try :
- from drive import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file
-
-
-
- except ImportError as drive_import_err :
- print (f"ERROR importing from drive.py: {drive_import_err }. External drive downloads will fail.")
-except ImportError :
- print ("ERROR: mega.py library not found. Please install it: pip install mega.py")
-try :
- from PIL import Image
-except ImportError :
- print ("ERROR: Pillow library not found. Please install it: pip install Pillow")
- Image =None
-try :
- from multipart_downloader import download_file_in_parts
- MULTIPART_DOWNLOADER_AVAILABLE =True
-except ImportError as e :
- print (f"Warning: multipart_downloader.py not found or import error: {e }. Multi-part downloads will be disabled.")
- MULTIPART_DOWNLOADER_AVAILABLE =False
- def download_file_in_parts (*args ,**kwargs ):return False ,0 ,None ,None
-from io import BytesIO
-STYLE_POST_TITLE ="post_title"
-STYLE_ORIGINAL_NAME ="original_name"
-STYLE_DATE_BASED ="date_based"
-STYLE_DATE_POST_TITLE ="date_post_title"
-MANGA_DATE_PREFIX_DEFAULT =""
-STYLE_POST_TITLE_GLOBAL_NUMBERING ="post_title_global_numbering"
-SKIP_SCOPE_FILES ="files"
-SKIP_SCOPE_POSTS ="posts"
-SKIP_SCOPE_BOTH ="both"
-CHAR_SCOPE_TITLE ="title"
-CHAR_SCOPE_FILES ="files"
-CHAR_SCOPE_BOTH ="both"
-CHAR_SCOPE_COMMENTS ="comments"
-FILE_DOWNLOAD_STATUS_SUCCESS ="success"
-FILE_DOWNLOAD_STATUS_SKIPPED ="skipped"
-FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER ="failed_retry_later"
-FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION ="failed_permanent_session"
-fastapi_app =None
-KNOWN_NAMES =[]
-MIN_SIZE_FOR_MULTIPART_DOWNLOAD =10 *1024 *1024
-GOFILE_GUEST_TOKEN =None
-MAX_PARTS_FOR_MULTIPART_DOWNLOAD =15
-MAX_FILENAME_COMPONENT_LENGTH =150
-IMAGE_EXTENSIONS ={
-'.jpg','.jpeg','.png','.gif','.bmp','.tiff','.tif','.webp',
-'.heic','.heif','.svg','.ico','.jfif','.pjpeg','.pjp','.avif'
-}
-VIDEO_EXTENSIONS ={
-'.mp4','.mov','.mkv','.webm','.avi','.wmv','.flv','.mpeg',
-'.mpg','.m4v','.3gp','.ogv','.ts','.vob'
-}
-ARCHIVE_EXTENSIONS ={
-'.zip','.rar','.7z','.tar','.gz','.bz2'
-}
-AUDIO_EXTENSIONS ={
-'.mp3','.wav','.aac','.flac','.ogg','.wma','.m4a','.opus',
-'.aiff','.ape','.mid','.midi'
-}
-FOLDER_NAME_STOP_WORDS ={
-"a","alone","am","an","and","at","be","blues","but","by","com",
-"for","grown","hard","he","her","his","hitting","i","im","in","is","it","its",
-"me","much","my","net","not","of","on","or","org","our","please",
-"right","s","she","so","technically","tell","the","their","they","this",
-"to","ve","was","we","well","were","with","www","year","you","your",
-}
-
-CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS ={
-"poll","cover","fan-art","fanart","requests","request","holiday","suggest","suggestions",
-"batch","open","closed","winner","loser","minor","adult","wip",
-"update","news","discussion","question","stream","video","sketchbook","artwork",
-
-"1","2","3","4","5","6","7","8","9","10",
-"11","12","13","14","15","16","17","18","19","20",
-"one","two","three","four","five","six","seven","eight","nine","ten",
-"eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen",
-"eighteen","nineteen","twenty",
-
-"jan","january","feb","february","mar","march","apr","april",
-"may","jun","june","jul","july","aug","august","sep","september",
-"oct","october","nov","november","dec","december",
-
-"mon","monday","tue","tuesday","wed","wednesday","thu","thursday",
-"fri","friday","sat","saturday","sun","sunday"
-}
-
-
-KNOWN_TXT_MATCH_CLEANUP_PATTERNS =[
-r'\bcum\b',
-r'\bnsfw\b',
-r'\bsfw\b',
-r'\bweb\b',
-r'\bhd\b',
-r'\bhi\s*res\b',
-r'\bhigh\s*res\b',
-r'\b\d+p\b',
-r'\b\d+k\b',
-r'\[OC\]',
-r'\[Request(?:s)?\]',
-r'\bCommission\b',
-r'\bComm\b',
-r'\bPreview\b',
-]
-
-def parse_cookie_string (cookie_string ):
- """Parses a 'name=value; name2=value2' cookie string into a dict."""
- cookies ={}
- if cookie_string :
- for item in cookie_string .split (';'):
- parts =item .split ('=',1 )
- if len (parts )==2 :
- name =parts [0 ].strip ()
- value =parts [1 ].strip ()
- if name :
- cookies [name ]=value
- return cookies if cookies else None
-def load_cookies_from_netscape_file (filepath ,logger_func ,target_domain_filter =None ):
- """Loads cookies from a Netscape-formatted cookies.txt file.
- If target_domain_filter is provided, only cookies for that domain (or its subdomains) are returned.
- """
- cookies ={}
- loaded_for_target_domain_count =0
- total_cookies_in_file =0
- try :
- with open (filepath ,'r',encoding ='utf-8')as f :
- for line_num ,line in enumerate (f ,1 ):
- line =line .strip ()
- if not line or line .startswith ('#'):
- continue
- parts =line .split ('\t')
- total_cookies_in_file +=1
- if len (parts )==7 :
- cookie_domain_from_file =parts [0 ]
- name =parts [5 ]
- value =parts [6 ]
- if name :
- if target_domain_filter :
-
-
-
- host_to_match =target_domain_filter .lower ()
- cookie_domain_norm =cookie_domain_from_file .lower ()
- is_match =False
- if cookie_domain_norm .startswith ('.'):
-
-
-
- if host_to_match ==cookie_domain_norm [1 :]or host_to_match .endswith (cookie_domain_norm ):
- is_match =True
- else :
-
-
- if host_to_match ==cookie_domain_norm :
- is_match =True
- if is_match :
- cookies [name ]=value
- loaded_for_target_domain_count +=1
- else :
- cookies [name ]=value
- if target_domain_filter :
- logger_func (f" 🍪 Scanned {total_cookies_in_file } cookies in '{os .path .basename (filepath )}'. Loaded {loaded_for_target_domain_count } for domain '{target_domain_filter }'.")
- else :
- logger_func (f" 🍪 Loaded {len (cookies )} cookies from '{os .path .basename (filepath )}' (no domain filter).")
- return cookies if cookies else None
- except FileNotFoundError :
- logger_func (f" 🍪 Cookie file '{os .path .basename (filepath )}' not found at expected location.")
- return None
- except Exception as e :
- logger_func (f" 🍪 Error parsing cookie file '{os .path .basename (filepath )}': {e }")
- return None
-def is_title_match_for_character (post_title ,character_name_filter ):
- if not post_title or not character_name_filter :
- return False
- safe_filter =str (character_name_filter ).strip ()
- if not safe_filter :
- return False
- pattern =r"(?i)\b"+re .escape (safe_filter )+r"\b"
- match_result =bool (re .search (pattern ,post_title ))
- return match_result
-def is_filename_match_for_character (filename ,character_name_filter ):
- if not filename or not character_name_filter :
- return False
- safe_filter =str (character_name_filter ).strip ().lower ()
- if not safe_filter :
- return False
- match_result =safe_filter in filename .lower ()
- return match_result
-def clean_folder_name (name ):
- if not isinstance (name ,str ):name =str (name )
- cleaned =re .sub (r'[^\w\s\-\_\.\(\)]','',name )
- cleaned =cleaned .strip ()
- cleaned =re .sub (r'\s+',' ',cleaned )
- if cleaned :
- words =cleaned .split (' ')
- filtered_words =[word for word in words if word .lower ()not in FOLDER_NAME_STOP_WORDS and word ]
- cleaned =' '.join (filtered_words )
- cleaned =cleaned .strip ()
- if not cleaned :
- return "untitled_folder"
- if len (cleaned )>MAX_FILENAME_COMPONENT_LENGTH :
- cleaned =cleaned [:MAX_FILENAME_COMPONENT_LENGTH ]
- temp_name =cleaned
- while len (temp_name )>0 and (temp_name .endswith ('.')or temp_name .endswith (' ')):
- temp_name =temp_name [:-1 ]
- return temp_name if temp_name else "untitled_folder"
-def clean_filename (name ):
- if not isinstance (name ,str ):name =str (name )
- cleaned =re .sub (r'[^\w\s\-\_\.\(\)]','',name )
- cleaned =cleaned .strip ()
- cleaned =re .sub (r'\s+',' ',cleaned )
- if not cleaned :return "untitled_file"
- base_name ,ext =os .path .splitext (cleaned )
- max_base_len =MAX_FILENAME_COMPONENT_LENGTH -len (ext )
- if len (base_name )>max_base_len :
- if max_base_len >0 :
- base_name =base_name [:max_base_len ]
- else :
- return cleaned [:MAX_FILENAME_COMPONENT_LENGTH ]if cleaned else "untitled_file"
- final_name =base_name +ext
- return final_name if final_name else "untitled_file"
-def strip_html_tags (html_text ):
- if not html_text :return ""
- text =html .unescape (str (html_text ))
- text_after_tag_removal =re .sub (r'<[^>]+>',' ',text )
- cleaned_text =re .sub (r'\s+',' ',text_after_tag_removal ).strip ()
- return cleaned_text
-def extract_folder_name_from_title (title ,unwanted_keywords ):
- if not title :return 'Uncategorized'
- title_lower =title .lower ()
- tokens =re .findall (r'\b[\w\-]+\b',title_lower )
- for token in tokens :
- clean_token =clean_folder_name (token )
- if clean_token and clean_token .lower ()not in unwanted_keywords :
- return clean_token
- cleaned_full_title =clean_folder_name (title )
- return cleaned_full_title if cleaned_full_title else 'Uncategorized'
-def match_folders_from_title (title ,names_to_match ,unwanted_keywords ):
- """
- Matches folder names from a title based on a list of known name objects.
- Each name object in names_to_match is expected to be a dict:
- {'name': 'PrimaryFolderName', 'aliases': ['alias1', 'alias2', ...]}
- """
- if not title or not names_to_match :
- return []
-
-
- cleaned_title_for_matching =title
- for pat_str in KNOWN_TXT_MATCH_CLEANUP_PATTERNS :
- cleaned_title_for_matching =re .sub (pat_str ,' ',cleaned_title_for_matching ,flags =re .IGNORECASE )
-
-
- cleaned_title_for_matching =re .sub (r'\s+',' ',cleaned_title_for_matching ).strip ()
-
- title_lower =cleaned_title_for_matching .lower ()
- matched_cleaned_names =set ()
- sorted_name_objects =sorted (names_to_match ,key =lambda x :len (x .get ("name","")),reverse =True )
- for name_obj in sorted_name_objects :
- primary_folder_name =name_obj .get ("name")
- aliases =name_obj .get ("aliases",[])
- if not primary_folder_name or not aliases :
- continue
- for alias in aliases :
- alias_lower =alias .lower ()
- if not alias_lower :continue
- pattern =r'\b'+re .escape (alias_lower )+r'\b'
- if re .search (pattern ,title_lower ):
- cleaned_primary_name =clean_folder_name (primary_folder_name )
- if cleaned_primary_name .lower ()not in unwanted_keywords :
- matched_cleaned_names .add (cleaned_primary_name )
- break
- return sorted (list (matched_cleaned_names ))
-
-def match_folders_from_filename_enhanced (filename ,names_to_match ,unwanted_keywords ):
- if not filename or not names_to_match :
- return []
-
- filename_lower =filename .lower ()
- matched_primary_names =set ()
-
-
-
- alias_map_to_primary =[]
- for name_obj in names_to_match :
- primary_folder_name =name_obj .get ("name")
- if not primary_folder_name :
- continue
-
- cleaned_primary_name =clean_folder_name (primary_folder_name )
-
- if not cleaned_primary_name or cleaned_primary_name .lower ()in unwanted_keywords :
- continue
-
- aliases_for_obj =name_obj .get ("aliases",[])
- for alias in aliases_for_obj :
- alias_lower =alias .lower ()
- if alias_lower :
- alias_map_to_primary .append ((alias_lower ,cleaned_primary_name ))
-
- alias_map_to_primary .sort (key =lambda x :len (x [0 ]),reverse =True )
-
- for alias_lower ,primary_name_for_alias in alias_map_to_primary :
- if filename_lower .startswith (alias_lower ):
- if primary_name_for_alias not in matched_primary_names :
- matched_primary_names .add (primary_name_for_alias )
-
- return sorted (list (matched_primary_names ))
-
-def is_image (filename ):
- if not filename :return False
- _ ,ext =os .path .splitext (filename )
- return ext .lower ()in IMAGE_EXTENSIONS
-def is_video (filename ):
- if not filename :return False
- _ ,ext =os .path .splitext (filename )
- return ext .lower ()in VIDEO_EXTENSIONS
-def is_zip (filename ):
- if not filename :return False
- return filename .lower ().endswith ('.zip')
-def is_rar (filename ):
- if not filename :return False
- return filename .lower ().endswith ('.rar')
-def is_archive (filename ):
- if not filename :return False
- _ ,ext =os .path .splitext (filename )
- return ext .lower ()in ARCHIVE_EXTENSIONS
-def is_audio (filename ):
- if not filename :return False
- _ ,ext =os .path .splitext (filename )
- return ext .lower ()in AUDIO_EXTENSIONS
-def is_post_url (url ):
- if not isinstance (url ,str ):return False
- return '/post/'in urlparse (url ).path
-def extract_post_info (url_string ):
- service ,user_id ,post_id =None ,None ,None
- if not isinstance (url_string ,str )or not url_string .strip ():return None ,None ,None
- try :
- parsed_url =urlparse (url_string .strip ())
- domain =parsed_url .netloc .lower ()
- is_kemono =any (d in domain for d in ['kemono.su','kemono.party'])
- is_coomer =any (d in domain for d in ['coomer.su','coomer.party'])
- if not (is_kemono or is_coomer ):return None ,None ,None
- path_parts =[part for part in parsed_url .path .strip ('/').split ('/')if part ]
- if len (path_parts )>=3 and path_parts [1 ].lower ()=='user':
- service =path_parts [0 ]
- user_id =path_parts [2 ]
- if len (path_parts )>=5 and path_parts [3 ].lower ()=='post':
- post_id =path_parts [4 ]
- return service ,user_id ,post_id
- if len (path_parts )>=5 and path_parts [0 ].lower ()=='api'and path_parts [1 ].lower ()=='v1'and path_parts [3 ].lower ()=='user':
- service =path_parts [2 ]
- user_id =path_parts [4 ]
- if len (path_parts )>=7 and path_parts [5 ].lower ()=='post':
- post_id =path_parts [6 ]
- return service ,user_id ,post_id
- except Exception as e :
- print (f"Debug: Exception during extract_post_info for URL '{url_string }': {e }")
- return None ,None ,None
-def prepare_cookies_for_request (use_cookie_flag ,cookie_text_input ,selected_cookie_file_path_from_ui ,app_base_dir ,logger_func ,target_domain =None ):
- """Prepares a cookie dictionary from text input or cookies.txt file."""
- if not use_cookie_flag :
- return None
-
- attempted_paths =set ()
-
-
- if selected_cookie_file_path_from_ui :
- basename_selected =os .path .basename (selected_cookie_file_path_from_ui )
- is_relevant_selection =False
- if target_domain :
- if basename_selected ==f"{target_domain }_cookies.txt"or basename_selected =="cookies.txt":
- is_relevant_selection =True
- else :
- is_relevant_selection =True
-
- if is_relevant_selection :
- logger_func (f" 🍪 Attempting to load cookies from UI-selected file: '{basename_selected }' for domain '{target_domain or 'any'}'...")
- norm_selected_path =os .path .normpath (selected_cookie_file_path_from_ui )
- attempted_paths .add (norm_selected_path )
- cookies =load_cookies_from_netscape_file (selected_cookie_file_path_from_ui ,logger_func ,target_domain_filter =target_domain )
- if cookies :
- return cookies
- else :
- logger_func (f" ⚠️ Failed to load cookies from UI-selected file: '{basename_selected }'.")
- else :
- logger_func (f" ℹ️ UI-selected cookie file '{basename_selected }' is not specific to target domain '{target_domain }' or generic. Skipping it for this request, will try other sources.")
-
-
- if app_base_dir and target_domain :
- domain_specific_filename =f"{target_domain }_cookies.txt"
- domain_specific_path =os .path .join (app_base_dir ,domain_specific_filename )
- norm_domain_specific_path =os .path .normpath (domain_specific_path )
- if os .path .exists (domain_specific_path )and norm_domain_specific_path not in attempted_paths :
- logger_func (f" 🍪 Attempting to load domain-specific cookies: '{domain_specific_filename }' for '{target_domain }' from app directory...")
- attempted_paths .add (norm_domain_specific_path )
- cookies =load_cookies_from_netscape_file (domain_specific_path ,logger_func ,target_domain_filter =target_domain )
- if cookies :
- return cookies
- else :
- logger_func (f" ⚠️ Failed to load cookies from '{domain_specific_filename }' in app directory.")
-
-
- if app_base_dir :
- default_cookies_filename ="cookies.txt"
- default_cookies_path =os .path .join (app_base_dir ,default_cookies_filename )
- norm_default_path =os .path .normpath (default_cookies_path )
- if os .path .exists (default_cookies_path )and norm_default_path not in attempted_paths :
- logger_func (f" 🍪 Attempting to load default '{default_cookies_filename }' from app directory for domain '{target_domain or 'any'}'...")
- attempted_paths .add (norm_default_path )
- cookies =load_cookies_from_netscape_file (default_cookies_path ,logger_func ,target_domain_filter =target_domain )
- if cookies :
- return cookies
- else :
- logger_func (f" ⚠️ Failed to load cookies from default '{default_cookies_filename }' in app directory.")
-
-
- if cookie_text_input :
- logger_func (f" 🍪 Using cookies from UI text input for domain '{target_domain or 'any'}' (as file methods failed or were not applicable).")
- cookies =parse_cookie_string (cookie_text_input )
- if cookies :
- return cookies
- else :
- logger_func (" ⚠️ UI cookie text input was provided but was empty or invalid.")
-
- logger_func (f" 🍪 Cookie usage enabled for domain '{target_domain or 'any'}', but no valid cookies found from any source.")
- return None
-def fetch_posts_paginated (api_url_base ,headers ,offset ,logger ,cancellation_event =None ,pause_event =None ,cookies_dict =None ):
- if cancellation_event and cancellation_event .is_set ():
- logger (" Fetch cancelled before request.")
- raise RuntimeError ("Fetch operation cancelled by user.")
- if pause_event and pause_event .is_set ():
- logger (" Post fetching paused...")
- while pause_event .is_set ():
- if cancellation_event and cancellation_event .is_set ():
- logger (" Post fetching cancelled while paused.")
- raise RuntimeError ("Fetch operation cancelled by user.")
- time .sleep (0.5 )
- logger (" Post fetching resumed.")
- paginated_url =f'{api_url_base }?o={offset }'
- max_retries =3
- retry_delay =5
-
- for attempt in range (max_retries +1 ):
- if cancellation_event and cancellation_event .is_set ():
- raise RuntimeError ("Fetch operation cancelled by user during retry loop.")
-
- log_message =f" Fetching: {paginated_url } (Page approx. {offset //50 +1 })"
- if attempt >0 :
- log_message +=f" (Attempt {attempt +1 }/{max_retries +1 })"
- logger (log_message )
-
- try :
- response =requests .get (paginated_url ,headers =headers ,timeout =(15 ,90 ),cookies =cookies_dict )
- response .raise_for_status ()
-
- if 'application/json'not in response .headers .get ('Content-Type','').lower ():
- logger (f"⚠️ Unexpected content type from API: {response .headers .get ('Content-Type')}. Body: {response .text [:200 ]}")
- return []
-
- return response .json ()
-
- except (requests .exceptions .Timeout ,requests .exceptions .ConnectionError )as e :
- logger (f" ⚠️ Retryable network error on page fetch (Attempt {attempt +1 }): {e }")
- if attempt 0 :
- log_message +=f" (Attempt {attempt +1 }/{max_retries +1 })"
- logger (log_message )
-
- try :
- response =requests .get (comments_api_url ,headers =headers ,timeout =(10 ,30 ),cookies =cookies_dict )
- response .raise_for_status ()
-
- if 'application/json'not in response .headers .get ('Content-Type','').lower ():
- logger (f"⚠️ Unexpected content type from comments API: {response .headers .get ('Content-Type')}. Body: {response .text [:200 ]}")
- return []
-
- return response .json ()
-
- except (requests .exceptions .Timeout ,requests .exceptions .ConnectionError )as e :
- logger (f" ⚠️ Retryable network error on comment fetch (Attempt {attempt +1 }): {e }")
- if attempt 1 :
- current_offset_manga =(start_page -1 )*page_size
- logger (f" Manga Mode: Starting fetch from page {start_page } (offset {current_offset_manga }).")
- elif start_page :
- logger (f" Manga Mode: Starting fetch from page 1 (offset 0).")
- if end_page :
- logger (f" Manga Mode: Will fetch up to page {end_page }.")
- while True :
- if pause_event and pause_event .is_set ():
- logger (" Manga mode post fetching paused...")
- while pause_event .is_set ():
- if cancellation_event and cancellation_event .is_set ():
- logger (" Manga mode post fetching cancelled while paused.")
- break
- time .sleep (0.5 )
- if not (cancellation_event and cancellation_event .is_set ()):logger (" Manga mode post fetching resumed.")
- if cancellation_event and cancellation_event .is_set ():
- logger (" Manga mode post fetching cancelled.")
- break
- current_page_num_manga =(current_offset_manga //page_size )+1
- if end_page and current_page_num_manga >end_page :
- logger (f" Manga Mode: Reached specified end page ({end_page }). Stopping post fetch.")
- break
- try :
- posts_batch_manga =fetch_posts_paginated (api_base_url ,headers ,current_offset_manga ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api )
- if not isinstance (posts_batch_manga ,list ):
- logger (f"❌ API Error (Manga Mode): Expected list of posts, got {type (posts_batch_manga )}.")
- break
- if not posts_batch_manga :
- logger ("✅ Reached end of posts (Manga Mode fetch all).")
- if start_page and not end_page and current_page_num_manga 1 and not target_post_id :
- current_offset =(start_page -1 )*page_size
- current_page_num =start_page
- logger (f" Starting from page {current_page_num } (calculated offset {current_offset }).")
- while True :
- if pause_event and pause_event .is_set ():
- logger (" Post fetching loop paused...")
- while pause_event .is_set ():
- if cancellation_event and cancellation_event .is_set ():
- logger (" Post fetching loop cancelled while paused.")
- break
- time .sleep (0.5 )
- if not (cancellation_event and cancellation_event .is_set ()):logger (" Post fetching loop resumed.")
- if cancellation_event and cancellation_event .is_set ():
- logger (" Post fetching loop cancelled.")
- break
- if target_post_id and processed_target_post_flag :
- break
- if not target_post_id and end_page and current_page_num >end_page :
- logger (f"✅ Reached specified end page ({end_page }) for creator feed. Stopping.")
- break
- try :
- posts_batch =fetch_posts_paginated (api_base_url ,headers ,current_offset ,logger ,cancellation_event ,pause_event ,cookies_dict =cookies_for_api )
- if not isinstance (posts_batch ,list ):
- logger (f"❌ API Error: Expected list of posts, got {type (posts_batch )} at page {current_page_num } (offset {current_offset }).")
- break
- except RuntimeError as e :
- if "cancelled by user"in str (e ).lower ():
- logger (f"ℹ️ Pagination stopped due to cancellation: {e }")
- else :
- logger (f"❌ {e }\n Aborting pagination at page {current_page_num } (offset {current_offset }).")
- break
- except Exception as e :
- logger (f"❌ Unexpected error fetching page {current_page_num } (offset {current_offset }): {e }")
- traceback .print_exc ()
- break
- if not posts_batch :
- if target_post_id and not processed_target_post_flag :
- logger (f"❌ Target post {target_post_id } not found after checking all available pages (API returned no more posts at offset {current_offset }).")
- elif not target_post_id :
- if current_page_num ==(start_page or 1 ):
- logger (f"😕 No posts found on the first page checked (page {current_page_num }, offset {current_offset }).")
- else :
- logger (f"✅ Reached end of posts (no more content from API at offset {current_offset }).")
- break
- if target_post_id and not processed_target_post_flag :
- matching_post =next ((p for p in posts_batch if str (p .get ('id'))==str (target_post_id )),None )
- if matching_post :
- logger (f"🎯 Found target post {target_post_id } on page {current_page_num } (offset {current_offset }).")
- yield [matching_post ]
- processed_target_post_flag =True
- elif not target_post_id :
- yield posts_batch
- if processed_target_post_flag :
- break
- current_offset +=page_size
- current_page_num +=1
- time .sleep (0.6 )
- if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event .is_set ()):
- logger (f"❌ Target post {target_post_id } could not be found after checking all relevant pages (final check after loop).")
-def get_link_platform (url ):
- try :
- domain =urlparse (url ).netloc .lower ()
- if 'drive.google.com'in domain :return 'google drive'
- if 'mega.nz'in domain or 'mega.io'in domain :return 'mega'
- if 'dropbox.com'in domain :return 'dropbox'
- if 'patreon.com'in domain :return 'patreon'
- if 'gofile.io'in domain :return 'gofile'
- if 'instagram.com'in domain :return 'instagram'
- if 'twitter.com'in domain or 'x.com'in domain :return 'twitter/x'
- if 'discord.gg'in domain or 'discord.com/invite'in domain :return 'discord invite'
- if 'pixiv.net'in domain :return 'pixiv'
- if 'kemono.su'in domain or 'kemono.party'in domain :return 'kemono'
- if 'coomer.su'in domain or 'coomer.party'in domain :return 'coomer'
- parts =domain .split ('.')
- if len (parts )>=2 :
- if parts [-2 ]not in ['com','org','net','gov','edu','co']or len (parts )==2 :
- return parts [-2 ]
- elif len (parts )>=3 and parts [-3 ]not in ['com','org','net','gov','edu','co']:
- return parts [-3 ]
- else :
- return domain
- return 'external'
- except Exception :return 'unknown'
class PostProcessorSignals (QObject ):
progress_signal =pyqtSignal (str )
file_download_status_signal =pyqtSignal (bool )
@@ -815,8 +48,8 @@ class PostProcessorSignals (QObject ):
file_progress_signal =pyqtSignal (str ,object )
file_successfully_downloaded_signal =pyqtSignal (dict )
missed_character_post_signal =pyqtSignal (str ,str )
-class PostProcessorWorker :
-
+
+class PostProcessorWorker:
def __init__ (self ,post_data ,download_root ,known_names ,
filter_character_list ,emitter ,
unwanted_keywords ,filter_mode ,skip_zip ,skip_rar ,
@@ -2122,9 +1355,6 @@ class PostProcessorWorker :
except Exception as e:
self.logger(f"⚠️ Could not update session file for post {post_id}: {e}")
-
-
-
if not self .extract_links_only and (total_downloaded_this_post >0 or not (
(current_character_filters and (
(self .char_filter_scope ==CHAR_SCOPE_TITLE and not post_is_candidate_by_title_char_match )or
@@ -2149,13 +1379,8 @@ class PostProcessorWorker :
if self .check_cancel ():self .logger (f" Post {post_id } processing interrupted/cancelled.");
else :self .logger (f" Post {post_id } Summary: Downloaded={total_downloaded_this_post }, Skipped Files={total_skipped_this_post }")
-
if not self .extract_links_only and self .use_post_subfolders and total_downloaded_this_post ==0 :
-
-
-
-
path_to_check_for_emptiness =determined_post_save_path_for_history
try :
if os .path .isdir (path_to_check_for_emptiness )and not os .listdir (path_to_check_for_emptiness ):
@@ -2165,6 +1390,7 @@ class PostProcessorWorker :
self .logger (f" ⚠️ Could not remove empty post-specific subfolder '{path_to_check_for_emptiness }': {e_rmdir }")
return total_downloaded_this_post ,total_skipped_this_post ,kept_original_filenames_for_log ,retryable_failures_this_post ,permanent_failures_this_post ,history_data_for_this_post
+
class DownloadThread (QThread ):
progress_signal =pyqtSignal (str )
add_character_prompt_signal =pyqtSignal (str )
@@ -2322,7 +1548,7 @@ class DownloadThread (QThread ):
if self .manga_mode_active and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not self .extract_links_only and self .manga_global_file_counter_ref is None :
self .manga_global_file_counter_ref =[1 ,threading .Lock ()]
self .logger (f"ℹ️ [Thread] Manga Title+GlobalNum Mode: Initialized global counter at {self .manga_global_file_counter_ref [0 ]}.")
- worker_signals_obj =PostProcessorSignals ()
+ worker_signals_obj = PostProcessorSignals ()
try :
worker_signals_obj .progress_signal .connect (self .progress_signal )
worker_signals_obj .file_download_status_signal .connect (self .file_download_status_signal )
@@ -2423,9 +1649,6 @@ class DownloadThread (QThread ):
if not was_process_cancelled and not self .isInterruptionRequested ():
self .logger ("✅ All posts processed or end of content reached by DownloadThread.")
-
-
-
except Exception as main_thread_err :
self .logger (f"\n❌ Critical error within DownloadThread run loop: {main_thread_err }")
traceback .print_exc ()
@@ -2448,85 +1671,6 @@ class DownloadThread (QThread ):
self ._add_character_response =result
self .logger (f" (DownloadThread) Received character prompt response: {'Yes (added/confirmed)'if result else 'No (declined/failed)'}")
-def download_mega_file (mega_link ,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, optional): The directory to save the downloaded file.
- Defaults to the current directory.
- logger_func (callable, optional): Function to use for logging. Defaults to print.
- """
- logger_func ("Initializing Mega client...")
- try :
- mega_client =Mega ()
- except NameError :
- logger_func ("ERROR: Mega class not available. mega.py library might not be installed correctly.")
- raise ImportError ("Mega class not found. Is mega.py installed?")
-
- m =mega_client .login ()
-
- logger_func (f"Attempting to download from: {mega_link }")
-
- try :
-
-
- logger_func (f" Verifying Mega link and fetching attributes: {mega_link }")
- file_attributes =m .get_public_url_info (mega_link )
-
- if not file_attributes or not isinstance (file_attributes ,dict ):
- logger_func (f"❌ Error: Could not retrieve valid file information for the Mega link. Link might be invalid, expired, or a folder. Info received: {file_attributes }")
- raise ValueError (f"Invalid or inaccessible Mega link. get_public_url_info returned: {file_attributes }")
-
- expected_filename =file_attributes .get ('name')
- file_size =file_attributes .get ('size')
-
- if not expected_filename :
- logger_func (f"⚠️ Critical: File name ('name') not found in Mega link attributes. Attributes: {file_attributes }")
- raise ValueError (f"File name ('name') not found in Mega link attributes: {file_attributes }")
-
- logger_func (f" Link verified. Expected filename: '{expected_filename }'. Size: {file_size if file_size is not None else 'Unknown'} bytes.")
-
- if not os .path .exists (download_path ):
- logger_func (f"Download path '{download_path }' does not exist. Creating it...")
- os .makedirs (download_path ,exist_ok =True )
-
- logger_func (f"Starting download of '{expected_filename }' to '{download_path }'...")
-
-
- download_result =m .download_url (mega_link ,dest_path =download_path ,dest_filename =None )
-
- if download_result and isinstance (download_result ,tuple )and len (download_result )==2 :
- saved_filepath ,saved_filename =download_result
-
- if not os .path .isabs (saved_filepath )and dest_path :
- saved_filepath =os .path .join (os .path .abspath (dest_path ),saved_filename )
-
- logger_func (f"File downloaded successfully! Saved as: {saved_filepath }")
- if not os .path .exists (saved_filepath ):
- logger_func (f"⚠️ Warning: mega.py reported success but file '{saved_filepath }' not found on disk.")
-
- if saved_filename !=expected_filename :
- logger_func (f" Note: Saved filename '{saved_filename }' differs from initially expected '{expected_filename }'. This is usually fine.")
- else :
- logger_func (f"Download failed. The download_url method returned: {download_result }")
- raise Exception (f"Mega download_url did not return expected result or failed. Result: {download_result }")
-
- except PermissionError :
- logger_func (f"Error: Permission denied to write to '{download_path }'. Please check permissions.")
- raise
- except FileNotFoundError :
- logger_func (f"Error: The specified download path '{download_path }' is invalid or a component was not found.")
- raise
- except requests .exceptions .RequestException as e :
- logger_func (f"Error during request to Mega (network issue, etc.): {e }")
- raise
- except ValueError as ve :
- logger_func (f"ValueError during Mega processing (likely invalid link): {ve }")
- raise
- except Exception as e :
- if isinstance (e ,TypeError )and "'bool' object is not subscriptable"in str (e ):
- logger_func (" This specific TypeError occurred despite pre-flight checks. This might indicate a deeper issue with the mega.py library or a very transient API problem for this link.")
- traceback .print_exc ()
- raise
\ No newline at end of file
+class InterruptedError(Exception):
+ """Custom exception for handling cancellations gracefully."""
+ pass
diff --git a/src/i18n/__init__.py b/src/i18n/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/i18n/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/languages.py b/src/i18n/translator.py
similarity index 80%
rename from languages.py
rename to src/i18n/translator.py
index 0a13f60..777f8d2 100644
--- a/languages.py
+++ b/src/i18n/translator.py
@@ -1,563 +1,7 @@
-translations ={
-"en":{
-"settings_dialog_title":"Settings",
-"language_label":"Language:",
-"lang_english":"English",
-"lang_japanese":"日本語 (Japanese)",
-"theme_toggle_light":"Switch to Light Mode",
-"theme_toggle_dark":"Switch to Dark Mode",
-"theme_tooltip_light":"Change the application appearance to light.",
-"theme_tooltip_dark":"Change the application appearance to dark.",
-"ok_button":"OK",
-"appearance_group_title":"Appearance",
-"language_group_title":"Language Settings",
-"creator_post_url_label":"🔗 Kemono Creator/Post URL:",
-"download_location_label":"📁 Download Location:",
-"filter_by_character_label":"🎯 Filter by Character(s) (comma-separated):",
-"skip_with_words_label":"🚫 Skip with Words (comma-separated):",
-"remove_words_from_name_label":"✂️ Remove Words from name:",
-"filter_all_radio":"All",
-"filter_images_radio":"Images/GIFs",
-"filter_videos_radio":"Videos",
-"filter_archives_radio":"📦 Only Archives",
-"filter_links_radio":"🔗 Only Links",
-"filter_audio_radio":"🎧 Only Audio",
-"favorite_mode_checkbox_label":"⭐ Favorite Mode",
-"browse_button_text":"Browse...",
-"char_filter_scope_files_text":"Filter: Files",
-"char_filter_scope_files_tooltip":"Current Scope: Files\n\nFilters individual files by name. A post is kept if any file matches.\nOnly matching files from that post are downloaded.\nExample: Filter 'Tifa'. File 'Tifa_artwork.jpg' matches and is downloaded.\nFolder Naming: Uses character from matching filename.\n\nClick to cycle to: Both",
-"char_filter_scope_title_text":"Filter: Title",
-"char_filter_scope_title_tooltip":"Current Scope: Title\n\nFilters entire posts by their title. All files from a matching post are downloaded.\nExample: Filter 'Aerith'. Post titled 'Aerith's Garden' matches; all its files are downloaded.\nFolder Naming: Uses character from matching post title.\n\nClick to cycle to: Files",
-"char_filter_scope_both_text":"Filter: Both",
-"char_filter_scope_both_tooltip":"Current Scope: Both (Title then Files)\n\n1. Checks post title: If matches, all files from post are downloaded.\n2. If title doesn't match, checks filenames: If any file matches, only that file is downloaded.\nExample: Filter 'Cloud'.\n - Post 'Cloud Strife' (title match) -> all files downloaded.\n - Post 'Bike Chase' with 'Cloud_fenrir.jpg' (file match) -> only 'Cloud_fenrir.jpg' downloaded.\nFolder Naming: Prioritizes title match, then file match.\n\nClick to cycle to: Comments",
-"char_filter_scope_comments_text":"Filter: Comments (Beta)",
-"char_filter_scope_comments_tooltip":"Current Scope: Comments (Beta - Files first, then Comments as fallback)\n\n1. Checks filenames: If any file in the post matches the filter, the entire post is downloaded. Comments are NOT checked for this filter term.\n2. If no file matches, THEN checks post comments: If a comment matches, the entire post is downloaded.\nExample: Filter 'Barret'.\n - Post A: Files 'Barret_gunarm.jpg', 'other.png'. File 'Barret_gunarm.jpg' matches. All files from Post A downloaded. Comments not checked for 'Barret'.\n - Post B: Files 'dyne.jpg', 'weapon.gif'. Comments: '...a drawing of Barret Wallace...'. No file match for 'Barret'. Comment matches. All files from Post B downloaded.\nFolder Naming: Prioritizes character from file match, then from comment match.\n\nClick to cycle to: Title",
-"char_filter_scope_unknown_text":"Filter: Unknown",
-"char_filter_scope_unknown_tooltip":"Current Scope: Unknown\n\nThe character filter scope is in an unknown state. Please cycle or reset.\n\nClick to cycle to: Title",
-"skip_words_input_tooltip":"Enter words, comma-separated, to skip downloading certain content (e.g., WIP, sketch, preview).\n\n" "The 'Scope: [Type]' button next to this input cycles how this filter applies:\n" "- Scope: Files: Skips individual files if their names contain any of these words.\n" "- Scope: Posts: Skips entire posts if their titles contain any of these words.\n" "- Scope: Both: Applies both (post title first, then individual files if post title is okay).",
-"remove_words_input_tooltip":
-"Enter words, comma-separated, to remove from downloaded filenames (case-insensitive).\n"
-"Useful for cleaning up common prefixes/suffixes.\n"
-"Example: patreon, kemono, [HD], _final",
-"skip_scope_files_text":"Scope: Files",
-"skip_scope_files_tooltip":"Current Skip Scope: Files\n\nSkips individual files if their names contain any of the 'Skip with Words'.\nExample: Skip words \"WIP, sketch\".\n- File \"art_WIP.jpg\" -> SKIPPED.\n- File \"final_art.png\" -> DOWNLOADED (if other conditions met).\n\nPost is still processed for other non-skipped files.\nClick to cycle to: Both",
-"skip_scope_posts_text":"Scope: Posts",
-"skip_scope_posts_tooltip":"Current Skip Scope: Posts\n\nSkips entire posts if their titles contain any of the 'Skip with Words'.\nAll files from a skipped post are ignored.\nExample: Skip words \"preview, announcement\".\n- Post \"Exciting Announcement!\" -> SKIPPED.\n- Post \"Finished Artwork\" -> PROCESSED (if other conditions met).\n\nClick to cycle to: Files",
-"skip_scope_both_text":"Scope: Both",
-"skip_scope_both_tooltip":"Current Skip Scope: Both (Posts then Files)\n\n1. Checks post title: If title contains a skip word, the entire post is SKIPPED.\n2. If post title is OK, then checks individual filenames: If a filename contains a skip word, only that file is SKIPPED.\nExample: Skip words \"WIP, sketch\".\n- Post \"Sketches and WIPs\" (title match) -> ENTIRE POST SKIPPED.\n- Post \"Art Update\" (title OK) with files:\n - \"character_WIP.jpg\" (file match) -> SKIPPED.\n - \"final_scene.png\" (file OK) -> DOWNLOADED.\n\nClick to cycle to: Posts",
-"skip_scope_unknown_text":"Scope: Unknown",
-"skip_scope_unknown_tooltip":"Current Skip Scope: Unknown\n\nThe skip words scope is in an unknown state. Please cycle or reset.\n\nClick to cycle to: Posts",
-"language_change_title":"Language Changed",
-"language_change_message":"The language has been changed. A restart is required for all changes to take full effect.",
-"language_change_informative":"Would you like to restart the application now?",
-"restart_now_button":"Restart Now",
-"skip_zip_checkbox_label":"Skip .zip",
-"skip_rar_checkbox_label":"Skip .rar",
-"download_thumbnails_checkbox_label":"Download Thumbnails Only",
-"scan_content_images_checkbox_label":"Scan Content for Images",
-"compress_images_checkbox_label":"Compress to WebP",
-"separate_folders_checkbox_label":"Separate Folders by Name/Title",
-"subfolder_per_post_checkbox_label":"Subfolder per Post",
-"use_cookie_checkbox_label":"Use Cookie",
-"use_multithreading_checkbox_base_label":"Use Multithreading",
-"show_external_links_checkbox_label":"Show External Links in Log",
-"manga_comic_mode_checkbox_label":"Manga/Comic Mode",
-"threads_label":"Threads:",
-"start_download_button_text":"⬇️ Start Download",
-"start_download_button_tooltip":"Click to start the download or link extraction process with the current settings.",
-"start_download_discard_tooltip":"Click to start a new download. This will discard the previous interrupted session.",
-"extract_links_button_text":"🔗 Extract Links",
-"pause_download_button_text":"⏸️ Pause Download",
-"pause_download_button_tooltip":"Click to pause the ongoing download process.",
-"resume_download_button_text":"▶️ Resume Download",
-"resume_download_button_tooltip":"Click to resume the download.",
-"cancel_button_text":"❌ Cancel & Reset UI",
-"cancel_button_tooltip":"Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory).",
-"error_button_text":"Error",
-"error_button_tooltip":"View files skipped due to errors and optionally retry them.",
-"cancel_retry_button_text":"❌ Cancel Retry",
-"known_chars_label_text":"🎭 Known Shows/Characters (for Folder Names):",
-"open_known_txt_button_text":"Open Known.txt",
-"known_chars_list_tooltip":"This list contains names used for automatic folder creation when 'Separate Folders' is on\nand no specific 'Filter by Character(s)' is provided or matches a post.\nAdd names of series, games, or characters you frequently download.",
-"open_known_txt_button_tooltip":"Open the 'Known.txt' file in your default text editor.\nThe file is located in the application's directory.",
-"add_char_button_text":"➕ Add",
-"add_char_button_tooltip":"Add the name from the input field to the 'Known Shows/Characters' list.",
-"add_to_filter_button_text":"⤵️ Add to Filter",
-"add_to_filter_button_tooltip":"Select names from 'Known Shows/Characters' list to add to the 'Filter by Character(s)' field above.",
-"delete_char_button_text":"🗑️ Delete Selected",
-"delete_char_button_tooltip":"Delete the selected name(s) from the 'Known Shows/Characters' list.",
-"progress_log_label_text":"📜 Progress Log:",
-"radio_all_tooltip":"Download all file types found in posts.",
-"radio_images_tooltip":"Download only common image formats (JPG, PNG, GIF, WEBP, etc.).",
-"radio_videos_tooltip":"Download only common video formats (MP4, MKV, WEBM, MOV, etc.).",
-"radio_only_archives_tooltip":"Exclusively download .zip and .rar files. Other file-specific options are disabled.",
-"radio_only_audio_tooltip":"Download only common audio formats (MP3, WAV, FLAC, etc.).",
-"radio_only_links_tooltip":"Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled.",
-"favorite_mode_checkbox_tooltip":"Enable Favorite Mode to browse saved artists/posts.\nThis will replace the URL input with Favorite selection buttons.",
-"skip_zip_checkbox_tooltip":"If checked, .zip archive files will not be downloaded.\n(Disabled if 'Only Archives' is selected).",
-"skip_rar_checkbox_tooltip":"If checked, .rar archive files will not be downloaded.\n(Disabled if 'Only Archives' is selected).",
-"download_thumbnails_checkbox_tooltip":"Downloads small preview images from the API instead of full-sized files (if available).\nIf 'Scan Post Content for Image URLs' is also checked, this mode will *only* download images found by the content scan (ignoring API thumbnails).",
-"scan_content_images_checkbox_tooltip":"If checked, the downloader will scan the HTML content of posts for image URLs (from
tags or direct links).\nThis includes resolving relative paths from
tags to full URLs.\nRelative paths in
tags (e.g., /data/image.jpg) will be resolved to full URLs.\nUseful for cases where images are in the post description but not in the API's file/attachment list.",
-"compress_images_checkbox_tooltip":"Compress images > 1.5MB to WebP format (requires Pillow).",
-"use_subfolders_checkbox_tooltip":"Create subfolders based on 'Filter by Character(s)' input or post titles.\nUses 'Known Shows/Characters' list as a fallback for folder names if no specific filter matches.\nEnables the 'Filter by Character(s)' input and 'Custom Folder Name' for single posts.",
-"use_subfolder_per_post_checkbox_tooltip":"Creates a subfolder for each post. If 'Separate Folders' is also on, it's inside the character/title folder.",
-"use_cookie_checkbox_tooltip":"If checked, will attempt to use cookies from 'cookies.txt' (Netscape format)\nin the application directory for requests.\nUseful for accessing content that requires login on Kemono/Coomer.",
-"cookie_text_input_tooltip":"Enter your cookie string directly.\nThis will be used if 'Use Cookie' is checked AND 'cookies.txt' is not found or this field is not empty.\nThe format depends on how the backend will parse it (e.g., 'name1=value1; name2=value2').",
-"use_multithreading_checkbox_tooltip":"Enables concurrent operations. See 'Threads' input for details.",
-"thread_count_input_tooltip":(
-"Number of concurrent operations.\n- Single Post: Concurrent file downloads (1-10 recommended).\n"
-"- Creator Feed URL: Number of posts to process simultaneously (1-200 recommended).\n"
-" Files within each post are downloaded one by one by its worker.\nIf 'Use Multithreading' is unchecked, 1 thread is used."),
-"external_links_checkbox_tooltip":"If checked, a secondary log panel appears below the main log to display external links found in post descriptions.\n(Disabled if 'Only Links' or 'Only Archives' mode is active).",
-"manga_mode_checkbox_tooltip":"Downloads posts from oldest to newest and renames files based on post title (for creator feeds only).","multipart_on_button_text":"Multi-part: ON",
-"multipart_on_button_tooltip":"Multi-part Download: ON\n\nEnables downloading large files in multiple segments simultaneously.\n- Can speed up downloads for single large files (e.g., videos).\n- May increase CPU/network usage.\n- For feeds with many small files, this might not offer speed benefits and could make UI/log busy.\n- If multi-part fails, it retries as single-stream.\n\nClick to turn OFF.",
-"multipart_off_button_text":"Multi-part: OFF",
-"multipart_off_button_tooltip":"Multi-part Download: OFF\n\nAll files downloaded using a single stream.\n- Stable and works well for most scenarios, especially many smaller files.\n- Large files downloaded sequentially.\n\nClick to turn ON (see advisory).",
-"reset_button_text":"🔄 Reset",
-"reset_button_tooltip":"Reset all inputs and logs to default state (only when idle).",
-"progress_idle_text":"Progress: Idle",
-"missed_character_log_label_text":"🚫 Missed Character Log:",
-"creator_popup_title":"Creator Selection",
-"creator_popup_search_placeholder":"Search by name, service, or paste creator URL...",
-"creator_popup_add_selected_button":"Add Selected",
-"creator_popup_scope_characters_button":"Scope: Characters",
-"creator_popup_posts_area_title": "Fetched Posts",
-"creator_popup_posts_search_placeholder": "Search fetched posts by title...",
-"no_posts_fetched_yet_status": "No posts fetched yet.",
-"fetched_posts_count_label": "Fetched {count} post(s). Select to add to queue.",
-"no_posts_found_for_selection": "No posts found for selected creator(s).",
-"fetched_posts_count_label_filtered": "Displaying {count} post(s) matching filter.",
-"no_posts_match_search_filter": "No posts match your search filter.",
-"fetch_error_for_creator_label": "Error fetching for {creator_name}",
-"post_fetch_cancelled_status_done": "Post fetching cancelled.",
-"failed_to_fetch_or_no_posts_label": "Failed to fetch posts or no posts found.",
-"select_posts_to_queue_message": "Please select at least one post to add to the queue.",
-"items_in_queue_placeholder": "{count} items in queue from popup.",
-"post_fetch_finished_status": "Finished fetching posts for selected creators.",
-"fetch_posts_button_text": "Fetch Posts",
-"creator_popup_add_posts_to_queue_button": "Add Selected Posts to Queue",
-"column_header_post_title": "Post Title",
-"posts_for_creator_header": "Posts for",
-"untitled_post_placeholder": "Untitled Post",
-"no_creators_to_fetch_status": "No creators selected to fetch posts for.",
-"post_fetch_cancelled_status": "Post fetching cancellation requested...",
-"creator_popup_scope_creators_button":"Scope: Creators",
-"favorite_artists_button_text":"🖼️ Favorite Artists",
-"favorite_artists_button_tooltip":"Browse and download from your favorite artists on Kemono.su/Coomer.su.",
-"favorite_posts_button_text":"📄 Favorite Posts",
-"favorite_posts_button_tooltip":"Browse and download your favorite posts from Kemono.su/Coomer.su.",
-"favorite_scope_selected_location_text":"Scope: Selected Location",
-"favorite_scope_selected_location_tooltip":"Current Favorite Download Scope: Selected Location\n\nAll selected favorite artists/posts will be downloaded into the main 'Download Location' specified in the UI.\nFilters (character, skip words, file type) will apply globally to all content.\n\nClick to change to: Artist Folders",
-"favorite_scope_artist_folders_text":"Scope: Artist Folders",
-"favorite_scope_artist_folders_tooltip":"Current Favorite Download Scope: Artist Folders\n\nFor each selected favorite artist/post, a new subfolder (named after the artist) will be created inside the main 'Download Location'.\nContent for that artist/post will be downloaded into their specific subfolder.\nFilters (character, skip words, file type) will apply *within* each artist's folder.\n\nClick to change to: Selected Location",
-"favorite_scope_unknown_text":"Scope: Unknown",
-"favorite_scope_unknown_tooltip":"Favorite download scope is unknown. Click to cycle.",
-"manga_style_post_title_text":"Name: Post Title",
-"manga_style_original_file_text":"Name: Original File",
-"manga_style_date_based_text":"Name: Date Based",
-"manga_style_title_global_num_text":"Name: Title+G.Num",
-"manga_style_date_post_title_text":"Name: Date + Title",
-"manga_style_unknown_text":"Name: Unknown Style",
-"manga_style_post_title_tooltip":"""Files are named based on the post's title.
-- The first file in a post is named using the cleaned post title (e.g., 'My Chapter 1.jpg').
-- If the post has multiple files, subsequent files are also named using the post title, but with a numeric suffix like '_1', '_2', etc. (e.g., 'My Chapter 1_1.png', 'My Chapter 1_2.gif'). The counter for the suffix starts from 1 for the second file.
-- If a post has only one file, it's named after the post title without a suffix.
+translations = {}
-Example: Post 'Chapter One' (3 files: originalA.jpg, originalB.png, originalC.gif)
-Output: 'Chapter One.jpg', 'Chapter One_1.png', 'Chapter One_2.gif'.""",
-"manga_style_original_file_tooltip":"Files attempt to keep their original filenames.\n\n- An optional prefix can be entered in the input field that appears next to this button.\n\nExample (with prefix 'MySeries'): 'MySeries_OriginalFile.jpg'.\nExample (no prefix): 'OriginalFile.jpg'.",
-"manga_style_date_based_tooltip":"Files are named sequentially (e.g., 001.ext, 002.ext) based on post publication order.\n\n- An optional prefix can be entered in the input field that appears next to this button.\n- Multithreading for post processing is disabled for this style to ensure correct numbering.\n\nExample (with prefix 'MyComic'): 'MyComic_001.jpg', 'MyComic_002.png'.\nExample (no prefix): '001.jpg', '002.png'.",
-"manga_style_title_global_num_tooltip":"Files are named with the post's title and a global sequential number across all posts.\n\n- Format: '[Cleaned Post Title]_[Global Counter].[ext]'\n- The counter (e.g., _001, _002) increments for every file downloaded in the current session.\n- Multithreading for post processing is disabled for this style to ensure correct numbering.\n\nExample: Post 'Chapter 1' (2 files) -> 'Chapter 1_001.jpg', 'Chapter 1_002.png'.\nNext post 'Chapter 2' (1 file) -> 'Chapter 2_003.jpg'.",
-"manga_style_date_post_title_tooltip":"""Files are named using the post's publication date and its title.
-- Format: '[YYYY-MM-DD]_[Cleaned Post Title].[ext]'
-- The date is taken from the post's 'published' or 'added' field.
-- If a post has multiple files, subsequent files (after the first) get a numeric suffix like '_1', '_2'.
-
-Example: Post 'Chapter One' (published 2023-01-15, 2 files: a.jpg, b.png)
-Output: '2023-01-15_ChapterOne.jpg', '2023-01-15_ChapterOne_1.png'""",
-"manga_style_unknown_tooltip":"The manga filename style is currently unknown. This is unexpected. Please cycle to a valid style.",
-"manga_style_cycle_tooltip_suffix":"Click to cycle to the next style.",
-"fav_artists_dialog_title":"Favorite Artists",
-"fav_artists_loading_status":"Loading favorite artists...",
-"fav_artists_search_placeholder":"Search artists...",
-"fav_artists_select_all_button":"Select All",
-"fav_artists_deselect_all_button":"Deselect All",
-"fav_artists_download_selected_button":"Download Selected",
-"fav_artists_cancel_button":"Cancel",
-"fav_artists_loading_from_source_status":"⏳ Loading favorites from {source_name}...",
-"fav_artists_found_status":"Found {count} total favorite artist(s).",
-"fav_artists_none_found_status":"No favorite artists found on Kemono.su or Coomer.su.",
-"fav_artists_failed_status":"Failed to fetch favorites.",
-"fav_artists_cookies_required_status":"Error: Cookies enabled but could not be loaded for any source.",
-"fav_artists_no_favorites_after_processing":"No favorite artists found after processing.",
-"fav_artists_no_selection_title":"No Selection",
-"fav_artists_no_selection_message":"Please select at least one artist to download.",
-
-"fav_posts_dialog_title":"Favorite Posts",
-"fav_posts_loading_status":"Loading favorite posts...",
-"fav_posts_search_placeholder":"Search posts (title, creator, ID, service)...",
-"fav_posts_select_all_button":"Select All",
-"fav_posts_deselect_all_button":"Deselect All",
-"fav_posts_download_selected_button":"Download Selected",
-"fav_posts_cancel_button":"Cancel",
-"fav_posts_cookies_required_error":"Error: Cookies are required for favorite posts but could not be loaded.",
-"fav_posts_auth_failed_title":"Authorization Failed (Posts)",
-"fav_posts_auth_failed_message":"Could not fetch favorites{domain_specific_part} due to an authorization error:\n\n{error_message}\n\nThis usually means your cookies are missing, invalid, or expired for the site. Please check your cookie setup.",
-"fav_posts_fetch_error_title":"Fetch Error",
-"fav_posts_fetch_error_message":"Error fetching favorites from {domain}{error_message_part}",
-"fav_posts_no_posts_found_status":"No favorite posts found.",
-"fav_posts_found_status":"{count} favorite post(s) found.",
-"fav_posts_display_error_status":"Error displaying posts: {error}",
-"fav_posts_ui_error_title":"UI Error",
-"fav_posts_ui_error_message":"Could not display favorite posts: {error}",
-"fav_posts_auth_failed_message_generic":"Could not fetch favorites{domain_specific_part} due to an authorization error. This usually means your cookies are missing, invalid, or expired for the site. Please check your cookie setup.",
-"key_fetching_fav_post_list_init":"Fetching list of favorite posts...",
-"key_fetching_from_source_kemono_su":"Fetching favorites from Kemono.su...",
-"key_fetching_from_source_coomer_su":"Fetching favorites from Coomer.su...",
-"fav_posts_fetch_cancelled_status":"Favorite post fetch cancelled.",
-"fetching_posts_for_creator_status_all_pages": "Fetching all posts for {creator_name} ({service})... This may take a while.",
-
-"known_names_filter_dialog_title":"Add Known Names to Filter",
-"known_names_filter_search_placeholder":"Search names...",
-"known_names_filter_select_all_button":"Select All",
-"known_names_filter_deselect_all_button":"Deselect All",
-"known_names_filter_add_selected_button":"Add Selected",
-
-"error_files_dialog_title":"Files Skipped Due to Errors",
-"error_files_no_errors_label":"No files were recorded as skipped due to errors in the last session or after retries.",
-"error_files_found_label":"The following {count} file(s) were skipped due to download errors:",
-"error_files_select_all_button":"Select All",
-"error_files_retry_selected_button":"Retry Selected",
-"error_files_export_urls_button":"Export URLs to .txt",
-"error_files_no_selection_retry_message":"Please select at least one file to retry.",
-"error_files_no_errors_export_title":"No Errors",
-"error_files_no_errors_export_message":"There are no error file URLs to export.",
-"error_files_no_urls_found_export_title":"No URLs Found",
-"error_files_no_urls_found_export_message":"Could not extract any URLs from the error file list to export.",
-"error_files_save_dialog_title":"Save Error File URLs",
-"error_files_export_success_title":"Export Successful",
-"error_files_export_success_message":"Successfully exported {count} entries to:\n{filepath}",
-"error_files_export_error_title":"Export Error",
-"error_files_export_error_message":"Could not export file links: {error}",
-"export_options_dialog_title":"Export Options",
-"export_options_description_label":"Choose the format for exporting error file links:",
-"export_options_radio_link_only":"Link per line (URL only)",
-"export_options_radio_link_only_tooltip":"Exports only the direct download URL for each failed file, one URL per line.",
-"export_options_radio_with_details":"Export with details (URL [Post, File info])",
-"export_options_radio_with_details_tooltip":"Exports the URL followed by details like Post Title, Post ID, and Original Filename in brackets.",
-"export_options_export_button":"Export",
-
-"no_errors_logged_title":"No Errors Logged",
-"no_errors_logged_message":"No files were recorded as skipped due to errors in the last session or after retries.",
-
-"progress_initializing_text":"Progress: Initializing...",
-"progress_posts_text":"Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)",
-"progress_processing_post_text":"Progress: Processing post {processed_posts}...",
-"progress_starting_text":"Progress: Starting...",
-"downloading_file_known_size_text":"Downloading '{filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)",
-"downloading_file_unknown_size_text":"Downloading '{filename}' ({downloaded_mb:.1f}MB)",
-"downloading_multipart_text":"DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts} parts @ {speed:.2f} MB/s)",
-"downloading_multipart_initializing_text":"File: {filename} - Initializing parts...",
-"status_completed":"Completed",
-"status_cancelled_by_user":"Cancelled by user",
-"files_downloaded_label":"downloaded",
-"files_skipped_label":"skipped",
-"retry_finished_text":"Retry Finished",
-"succeeded_text":"Succeeded",
-"empty_popup_button_tooltip_text":"Open Creator Selection (Browse creators.json)",
-"failed_text":"Failed",
-"ready_for_new_task_text":"Ready for new task."
-,"fav_mode_active_label_text":"⭐ Favorite Mode is active. Please select filters below before choosing your favorite artists/posts. Select action below.",
-"export_links_button_text":"Export Links",
-"download_extracted_links_button_text":"Download",
-"download_selected_button_text":"Download Selected",
-"link_input_placeholder_text":"e.g., https://kemono.su/patreon/user/12345 or .../post/98765",
-"link_input_tooltip_text":"Enter the full URL of a Kemono/Coomer creator's page or a specific post.\nExample (Creator): https://kemono.su/patreon/user/12345\nExample (Post): https://kemono.su/patreon/user/12345/post/98765",
-"dir_input_placeholder_text":"Select folder where downloads will be saved",
-"dir_input_tooltip_text":"Enter or browse to the main folder where all downloaded content will be saved.\nThis is required unless 'Only Links' mode is selected.",
-"character_input_placeholder_text":"e.g., Tifa, Aerith, (Cloud, Zack)",
-"custom_folder_input_placeholder_text":"Optional: Save this post to specific folder",
-"custom_folder_input_tooltip_text":"If downloading a single post URL AND 'Separate Folders by Name/Title' is enabled,\nyou can enter a custom name here for that post's download folder.\nExample: My Favorite Scene",
-"skip_words_input_placeholder_text":"e.g., WM, WIP, sketch, preview",
-"remove_from_filename_input_placeholder_text":"e.g., patreon, HD",
-"cookie_text_input_placeholder_no_file_selected_text":"Cookie string (if no cookies.txt selected)",
-"cookie_text_input_placeholder_with_file_selected_text":"Using selected cookie file (see Browse...)",
-"character_search_input_placeholder_text":"Search characters...",
-"character_search_input_tooltip_text":"Type here to filter the list of known shows/characters below.",
-"new_char_input_placeholder_text":"Add new show/character name",
-"new_char_input_tooltip_text":"Enter a new show, game, or character name to add to the list above.",
-"link_search_input_placeholder_text":"Search Links...",
-"link_search_input_tooltip_text":"When in 'Only Links' mode, type here to filter the displayed links by text, URL, or platform.",
-"manga_date_title_suffix_input_placeholder_text":"Suffix (replaces title)",
-"manga_date_title_suffix_input_tooltip_text":"Optional suffix for 'Date + Title' style.\nIf provided, this text will be used instead of the post title.\nExample: 'My Series Vol 1'",
-"history_button_tooltip_text":"View download history",
-"manga_date_prefix_input_placeholder_text":"Prefix for Manga Filenames",
-"manga_date_prefix_input_tooltip_text":"Optional prefix for 'Date Based' or 'Original File' manga filenames (e.g., 'Series Name').\nIf empty, files will be named based on the style without a prefix.",
-"log_display_mode_links_view_text":"🔗 Links View",
-"log_display_mode_progress_view_text":"⬇️ Progress View",
-"history_button_tooltip_text":"View download history",
-"empty_popup_button_tooltip_text":"Open Creator Selection (Browse creators.json)",
-"download_external_links_dialog_title":"Download Selected External Links",
-"select_all_button_text":"Select All",
-"deselect_all_button_text":"Deselect All",
-"settings_download_group_title": "Download Settings",
-"settings_save_path_button": "Save Current Download Path",
-"deselect_all_button_text":"Deselect All", # Existing, but good to have for context
-"settings_download_group_title": "Download Settings",
-"settings_save_path_button": "Save Current Download Path",
-"settings_save_path_tooltip": "Save the current 'Download Location' from the main window for future sessions.",
-"settings_save_path_success_title": "Path Saved",
-"settings_save_path_success_message": "Download location '{path}' saved successfully.",
-"settings_save_path_invalid_title": "Invalid Path",
-"settings_save_path_invalid_message": "The path '{path}' is not a valid directory. Please select a valid directory first.",
-"settings_save_path_empty_title": "Empty Path",
-"settings_save_path_empty_message": "Download location cannot be empty. Please select a path first.",
-"settings_save_all_settings_button_text": "Save All Settings",
-"settings_save_all_settings_button_tooltip": "Save all current application settings (download path, checkboxes, inputs, etc.).",
-"settings_all_saved_success_title": "Settings Saved",
-"settings_all_saved_success_message": "All application settings saved successfully.",
-"settings_all_saved_error_title": "Save Error",
-"settings_all_saved_error_message": "Could not save all application settings. Check the log for details.",
-"cookie_browse_button_tooltip":"Browse for a cookie file (Netscape format, typically cookies.txt).\nThis will be used if 'Use Cookie' is checked and the text field above is empty."
-,
-"page_range_label_text":"Page Range:",
-"start_page_input_placeholder":"Start",
-"start_page_input_tooltip":"For creator URLs: Specify the starting page number to download from (e.g., 1, 2, 3).\nLeave blank or set to 1 to start from the first page.\nDisabled for single post URLs or Manga/Comic Mode.",
-"page_range_to_label_text":"to",
-"end_page_input_placeholder":"End",
-"end_page_input_tooltip":"For creator URLs: Specify the ending page number to download up to (e.g., 5, 10).\nLeave blank to download all pages from the start page.\nDisabled for single post URLs or Manga/Comic Mode.",
-"known_names_help_button_tooltip_text":"Open the application feature guide.",
-"future_settings_button_tooltip_text":"Open application settings (Theme, Language, etc.).",
-"link_search_button_tooltip_text":"Filter displayed links",
-"confirm_add_all_dialog_title":"Confirm Adding New Names",
-"confirm_add_all_info_label":"The following new names/groups from your 'Filter by Character(s)' input are not in 'Known.txt'.\nAdding them can improve folder organization for future downloads.\n\nReview the list and choose an action:",
-"confirm_add_all_select_all_button":"Select All",
-"confirm_add_all_deselect_all_button":"Deselect All",
-"confirm_add_all_add_selected_button":"Add Selected to Known.txt",
-"confirm_add_all_skip_adding_button":"Skip Adding These",
-"confirm_add_all_cancel_download_button":"Cancel Download",
-"cookie_help_dialog_title":"Cookie File Instructions",
-"cookie_help_instruction_intro":"To use cookies, you typically need a cookies.txt file from your browser.
",
-"cookie_help_how_to_get_title":"How to get cookies.txt:
",
-"download_history_dialog_title_first_processed": "First Processed Files History",
-"first_files_processed_header": "First {count} Files Processed in this Session:",
-"history_file_label": "File:",
-"history_from_post_label": "From Post:",
-"history_post_uploaded_label": "Post Uploaded:",
-"history_file_downloaded_label": "File Downloaded:",
-"download_history_dialog_title_empty": "Download History (Empty)",
-"no_download_history_header": "No Downloads Yet",
-"cookie_help_step1_extension_intro":"Install the 'Get cookies.txt LOCALLY' extension for your Chrome-based browser:
Get cookies.txt LOCALLY on Chrome Web Store",
-"cookie_help_step2_login":"Go to the website (e.g., kemono.su or coomer.su) and log in if necessary.",
-"cookie_help_step3_click_icon":"Click the extension's icon in your browser toolbar.",
-"cookie_help_step4_export":"Click an 'Export' button (e.g., \"Export As\", \"Export cookies.txt\" - the exact wording might vary depending on the extension version).",
-"cookie_help_step5_save_file":"Save the downloaded cookies.txt file to your computer.",
-"cookie_help_step6_app_intro":"In this application:",
-"cookie_help_step6a_checkbox":"- Ensure the 'Use Cookie' checkbox is checked.
",
-"cookie_help_step6b_browse":"- Click the 'Browse...' button next to the cookie text field.
",
-"cookie_help_step6c_select":"- Select the
cookies.txt file you just saved.
",
-"cookie_help_alternative_paste":"Alternatively, some extensions might allow you to copy the cookie string directly. If so, you can paste it into the text field instead of browsing for a file.
",
-"cookie_help_proceed_without_button":"Download without Cookies",
-"cookie_help_cancel_download_button":"Cancel Download",
-"character_input_tooltip":(
-"Enter character names (comma-separated). Supports advanced grouping and affects folder naming "
-"if 'Separate Folders' is enabled.\n\n"
-"Examples:\n"
-"- Nami → Matches 'Nami', creates folder 'Nami'.\n"
-"- (Ulti, Vivi) → Matches either, folder 'Ulti Vivi', adds both to Known.txt separately.\n"
-"- (Boa, Hancock)~ → Matches either, folder 'Boa Hancock', adds as one group in Known.txt.\n\n"
-"Names are treated as aliases for matching.\n\n"
-"Filter Modes (button cycles):\n"
-"- Files: Filters by filename.\n"
-"- Title: Filters by post title.\n"
-"- Both: Title first, then filename.\n"
-"- Comments (Beta): Filename first, then post comments."
-),
-"tour_dialog_title":"Welcome to Kemono Downloader!",
-"tour_dialog_never_show_checkbox":"Never show this tour again",
-"tour_dialog_skip_button":"Skip Tour",
-"tour_dialog_back_button":"Back",
-"tour_dialog_next_button":"Next",
-"tour_dialog_finish_button":"Finish",
-"tour_dialog_step1_title":"👋 Welcome!",
-"tour_dialog_step1_content":"""Hello! This quick tour will walk you through the main features of the Kemono Downloader, including recent updates like enhanced filtering, manga mode improvements, and cookie management.
-
- - My goal is to help you easily download content from Kemono and Coomer.
- - 🎨 Creator Selection Button: Next to the URL input, click the palette icon to open a dialog. Browse and select creators from your
creators.json file to quickly add their names to the URL input.
- - Important Tip: App '(Not Responding)'?
- After clicking 'Start Download', especially for large creator feeds or with many threads, the application might temporarily show as '(Not Responding)'. Your operating system (Windows, macOS, Linux) might even suggest you 'End Process' or 'Force Quit'.
- Please be patient! The app is often still working hard in the background. Before force-closing, try checking your chosen 'Download Location' in your file explorer. If you see new folders being created or files appearing, it means the download is progressing correctly. Give it some time to become responsive again.
- - Use the Next and Back buttons to navigate.
- - Many options have tooltips if you hover over them for more details.
- - Click Skip Tour to close this guide at any time.
- - Check 'Never show this tour again' if you don't want to see this on future startups.
-
""",
-"tour_dialog_step2_title":"① Getting Started",
-"tour_dialog_step2_content":"""Let's start with the basics for downloading:
-
- - 🔗 Kemono Creator/Post URL:
- Paste the full web address (URL) of a creator's page (e.g., https://kemono.su/patreon/user/12345)
- or a specific post (e.g., .../post/98765).
- or a Coomer creator (e.g., https://coomer.su/onlyfans/user/artistname)
- - 📁 Download Location:
- Click 'Browse...' to choose a folder on your computer where all downloaded files will be saved.
- This is required unless you are using 'Only Links' mode.
- - 📄 Page Range (Creator URLs only):
- If downloading from a creator's page, you can specify a range of pages to fetch (e.g., pages 2 to 5).
- Leave blank for all pages. This is disabled for single post URLs or when Manga/Comic Mode is active.
-
""",
-"tour_dialog_step3_title":"② Filtering Downloads",
-"tour_dialog_step3_content":"""Refine what you download with these filters (most are disabled in 'Only Links' or 'Only Archives' modes):
-
- - 🎯 Filter by Character(s):
- Enter character names, comma-separated (e.g., Tifa, Aerith). Group aliases for a combined folder name: (alias1, alias2, alias3) becomes folder 'alias1 alias2 alias3' (after cleaning). All names in the group are used as aliases for matching.
- The 'Filter: [Type]' button (next to this input) cycles how this filter applies:
- - Filter: Files: Checks individual filenames. A post is kept if any file matches; only matching files are downloaded. Folder naming uses the character from the matching filename (if 'Separate Folders' is on).
- - Filter: Title: Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title.
- - ⤵️ Add to Filter Button (Known Names): Next to the 'Add' button for Known Names (see Step 5), this opens a popup. Select names from your
Known.txt list via checkboxes (with a search bar) to quickly add them to the 'Filter by Character(s)' field. Grouped names like (Boa, Hancock) from Known.txt will be added as (Boa, Hancock)~ to the filter.
- - Filter: Both: Checks post title first. If it matches, all files are downloaded. If not, it then checks filenames, and only matching files are downloaded. Folder naming prioritizes title match, then file match.
- - Filter: Comments (Beta): Checks filenames first. If a file matches, all files from the post are downloaded. If no file match, it then checks post comments. If a comment matches, all files are downloaded. (Uses more API requests). Folder naming prioritizes file match, then comment match.
- This filter also influences folder naming if 'Separate Folders by Name/Title' is enabled.
- - 🚫 Skip with Words:
- Enter words, comma-separated (e.g., WIP, sketch, preview).
- The 'Scope: [Type]' button (next to this input) cycles how this filter applies:
- - Scope: Files: Skips files if their names contain any of these words.
- - Scope: Posts: Skips entire posts if their titles contain any of these words.
- - Scope: Both: Applies both file and post title skipping (post first, then files).
- - Filter Files (Radio Buttons): Choose what to download:
-
- - All: Downloads all file types found.
- - Images/GIFs: Only common image formats and GIFs.
- - Videos: Only common video formats.
- - 📦 Only Archives: Exclusively downloads .zip and .rar files. When selected, 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show External Links' is also disabled.
- - 🎧 Only Audio: Only common audio formats (MP3, WAV, FLAC, etc.).
- - 🔗 Only Links: Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show External Links' are disabled.
-
-
""",
-"tour_dialog_step4_title":"③ Favorite Mode (Alternative Download)",
-"tour_dialog_step4_content":"""The application offers a 'Favorite Mode' for downloading content from artists you've favorited on Kemono.su.
-
- - ⭐ Favorite Mode Checkbox:
- Located next to the '🔗 Only Links' radio button. Check this to activate Favorite Mode.
- - What Happens in Favorite Mode:
-
- The '🔗 Kemono Creator/Post URL' input area is replaced with a message indicating Favorite Mode is active.
- - The standard 'Start Download', 'Pause', 'Cancel' buttons are replaced with '🖼️ Favorite Artists' and '📄 Favorite Posts' buttons (Note: 'Favorite Posts' is planned for the future).
- - The '🍪 Use Cookie' option is automatically enabled and locked, as cookies are required to fetch your favorites.
- - 🖼️ Favorite Artists Button:
- Click this to open a dialog listing your favorited artists from Kemono.su. You can select one or more artists to download.
- - Favorite Download Scope (Button):
- This button (next to 'Favorite Posts') controls where selected favorites are downloaded:
- - Scope: Selected Location: All selected artists are downloaded into the main 'Download Location' you've set. Filters apply globally.
- - Scope: Artist Folders: A subfolder (named after the artist) is created inside your main 'Download Location' for each selected artist. Content for that artist goes into their specific subfolder. Filters apply within each artist's folder.
- - Filters in Favorite Mode:
- The 'Filter by Character(s)', 'Skip with Words', and 'Filter Files' options still apply to the content downloaded from your selected favorite artists.
-
""",
-"tour_dialog_step5_title":"④ Fine-Tuning Downloads",
-"tour_dialog_step5_content":"""More options to customize your downloads:
-
- - Skip .zip / Skip .rar: Check these to avoid downloading these archive file types.
- (Note: These are disabled and ignored if '📦 Only Archives' filter mode is selected).
- - ✂️ Remove Words from name:
- Enter words, comma-separated (e.g., patreon, [HD]), to remove from downloaded filenames (case-insensitive).
- - Download Thumbnails Only: Downloads small preview images instead of full-sized files (if available).
- - Compress Large Images: If the 'Pillow' library is installed, images larger than 1.5MB will be converted to WebP format if the WebP version is significantly smaller.
- - 🗄️ Custom Folder Name (Single Post Only):
- If you are downloading a single specific post URL AND 'Separate Folders by Name/Title' is enabled,
- you can enter a custom name here for that post's download folder.
- - 🍪 Use Cookie: Check this to use cookies for requests. You can either:
-
- Enter a cookie string directly into the text field (e.g., name1=value1; name2=value2).
- - Click 'Browse...' to select a cookies.txt file (Netscape format). The path will appear in the text field.
- This is useful for accessing content that requires login. The text field takes precedence if filled.
- If 'Use Cookie' is checked but both the text field and browsed file are empty, it will try to load 'cookies.txt' from the app's directory.
-
""",
-"tour_dialog_step6_title":"⑤ Organization & Performance",
-"tour_dialog_step6_content":"""Organize your downloads and manage performance:
-
- - ⚙️ Separate Folders by Name/Title: Creates subfolders based on the 'Filter by Character(s)' input or post titles (can use the Known.txt list as a fallback for folder names).
- - Subfolder per Post: If 'Separate Folders' is on, this creates an additional subfolder for each individual post inside the main character/title folder.
- - 🚀 Use Multithreading (Threads): Enables faster operations. The number in 'Threads' input means:
-
- For Creator Feeds: Number of posts to process simultaneously. Files within each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).
- - For Single Post URLs: Number of files to download concurrently from that single post.
- If unchecked, 1 thread is used. High thread counts (e.g., >40) may show an advisory.
- - Multi-part Download Toggle (Top-right of log area):
- The 'Multi-part: [ON/OFF]' button allows enabling/disabling multi-segment downloads for individual large files.
- - ON: Can speed up large file downloads (e.g., videos) but may increase UI choppiness or log spam with many small files. An advisory will appear when enabling. If a multi-part download fails, it retries as single-stream.
- - OFF (Default): Files are downloaded in a single stream.
- This is disabled if 'Only Links' or 'Only Archives' mode is active.
- - 📖 Manga/Comic Mode (Creator URLs only): Tailored for sequential content.
-
- - Downloads posts from oldest to newest.
- - The 'Page Range' input is disabled as all posts are fetched.
- - A filename style toggle button (e.g., 'Name: Post Title') appears in the top-right of the log area when this mode is active for a creator feed. Click it to cycle through naming styles:
-
- - Name: Post Title (Default): The first file in a post is named after the post's cleaned title (e.g., 'My Chapter 1.jpg'). Subsequent files within the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics.
- - Name: Original File: All files attempt to keep their original filenames. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_OriginalFile.jpg'.
- - Name: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the post's cleaned title as a prefix, followed by a global counter. For example: Post 'Chapter 1' (2 files) -> 'Chapter 1_001.jpg', 'Chapter 1_002.png'. The next post, 'Chapter 2' (1 file), would continue the numbering -> 'Chapter 2_003.jpg'. Multithreading for post processing is automatically disabled for this style to ensure correct global numbering.
- - Name: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on post publication order. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style.
-
-
- - For best results with 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
-
- - 🎭 Known.txt for Smart Folder Organization:
- Known.txt (in the app's directory) allows fine-grained control over automatic folder organization when 'Separate Folders by Name/Title' is active.
-
- - How it Works: Each line in
Known.txt is an entry.
- - A simple line like
My Awesome Series means content matching this will go into a folder named "My Awesome Series".
- - A grouped line like
(Character A, Char A, Alt Name A) means content matching "Character A", "Char A", OR "Alt Name A" will ALL go into a single folder named "Character A Char A Alt Name A" (after cleaning). All terms in the parentheses become aliases for that folder.
- - Intelligent Fallback: When 'Separate Folders by Name/Title' is active, and if a post doesn't match any specific 'Filter by Character(s)' input, the downloader consults
Known.txt to find a matching primary name for folder creation.
- - User-Friendly Management: Add simple (non-grouped) names via the UI list below. For advanced editing (like creating/modifying grouped aliases), click 'Open Known.txt' to edit the file in your text editor. The app reloads it on next use or startup.
-
-
-
""",
-"tour_dialog_step7_title":"⑥ Common Errors & Troubleshooting",
-"tour_dialog_step7_content":"""Sometimes, downloads might encounter issues. Here are a few common ones:
-
- - Character Input Tooltip:
- Enter character names, comma-separated (e.g., Tifa, Aerith).
- Group aliases for a combined folder name: (alias1, alias2, alias3) becomes folder 'alias1 alias2 alias3'.
- All names in the group are used as aliases for matching content.
- The 'Filter: [Type]' button next to this input cycles how this filter applies:
- - Filter: Files: Checks individual filenames. Only matching files are downloaded.
- - Filter: Title: Checks post titles. All files from a matching post are downloaded.
- - Filter: Both: Checks post title first. If no match, then checks filenames.
- - Filter: Comments (Beta): Checks filenames first. If no match, then checks post comments.
- This filter also influences folder naming if 'Separate Folders by Name/Title' is enabled.
- - 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout:
- These usually indicate temporary server-side problems with Kemono/Coomer. The site might be overloaded, down for maintenance, or experiencing issues.
- Solution: Wait a while (e.g., 30 minutes to a few hours) and try again later. Check the site directly in your browser.
- - Connection Lost / Connection Refused / Timeout (during file download):
- This can happen due to your internet connection, server instability, or if the server drops the connection for a large file.
- Solution: Check your internet. Try reducing the number of 'Threads' if it's high. The app might prompt to retry some failed files at the end of a session.
- - IncompleteRead Error:
- The server sent less data than expected. Often a temporary network hiccup or server issue.
- Solution: The app will often mark these files for a retry attempt at the end of the download session.
- - 403 Forbidden / 401 Unauthorized (less common for public posts):
- You might not have permission to access the content. For some paywalled or private content, using the 'Use Cookie' option with valid cookies from your browser session might help. Ensure your cookies are fresh.
- - 404 Not Found:
- The post or file URL is incorrect, or the content has been removed from the site. Double-check the URL.
- - 'No posts found' / 'Target post not found':
- Ensure the URL is correct and the creator/post exists. If using page ranges, make sure they are valid for the creator. For very new posts, there might be a slight delay before they appear in the API.
- - General Slowness / App '(Not Responding)':
- As mentioned in Step 1, if the app seems to hang after starting, especially with large creator feeds or many threads, please give it time. It's likely processing data in the background. Reducing thread count can sometimes improve responsiveness if this is frequent.
-
""",
-"tour_dialog_step8_title":"⑦ Logs & Final Controls",
-"tour_dialog_step8_content":"""Monitoring and Controls:
-
- - 📜 Progress Log / Extracted Links Log: Shows detailed download messages. If '🔗 Only Links' mode is active, this area displays the extracted links.
- - Show External Links in Log: If checked, a secondary log panel appears below the main log to display any external links found in post descriptions. (This is disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).
- - Log View Toggle (👁️ / 🙈 Button):
- This button (top-right of log area) switches the main log view:
- - 👁️ Progress Log (Default): Shows all download activity, errors, and summaries.
- - 🙈 Missed Character Log: Displays a list of key terms from post titles that were skipped due to your 'Filter by Character(s)' settings. Useful for identifying content you might be unintentionally missing.
- - 🔄 Reset: Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.
- - ⬇️ Start Download / 🔗 Extract Links / ⏸️ Pause / ❌ Cancel: These buttons control the process. 'Cancel & Reset UI' stops the current operation and performs a soft UI reset, preserving your URL and Directory inputs. 'Pause/Resume' allows temporarily halting and continuing.
- - If some files fail with recoverable errors (like 'IncompleteRead'), you might be prompted to retry them at the end of a session.
-
-
You're all set! Click 'Finish' to close the tour and start using the downloader."""
-},
-}
-
-translations ["zh_TW"]={ "settings_dialog_title": "設定",
+translations ["zh_TW"]={
+ "settings_dialog_title": "設定",
"language_label": "語言:",
"lang_english": "英文 (English)",
"lang_japanese": "日文 (日本語)",
@@ -3161,532 +2605,648 @@ translations ["fr"]={
"help_guide_step9_content": "\nFichiers clés utilisés par l'application
\n\nKnown.txt :\n\n- Situé dans le répertoire de l'application (où se trouve le
.exe ou main.py). \n- Stocke votre liste de séries, personnages ou titres de séries connus pour l'organisation automatique des dossiers lorsque 'Dossiers séparés par Nom/Titre' est activé.
\n- Format :\n
\n- Chaque ligne est une entrée.
\n- Nom simple : par ex.,
Ma Super Série. Le contenu correspondant ira dans un dossier nommé \"Ma Super Série\". \n- Alias groupés : par ex.,
(Personnage A, Perso A, Nom Alt A). Le contenu correspondant à \"Personnage A\", \"Perso A\", OU \"Nom Alt A\" ira TOUS dans un seul dossier nommé \"Personnage A Perso A Nom Alt A\" (après nettoyage). Tous les termes entre parenthèses deviennent des alias pour ce dossier. \n
\n \n- Utilisation : Sert de solution de repli pour le nommage des dossiers si une publication ne correspond pas à votre entrée active 'Filtrer par Personnage(s)'. Vous pouvez gérer les entrées simples via l'UI ou éditer le fichier directement pour les alias complexes. L'application le recharge au démarrage ou à la prochaine utilisation.
\n
\n \ncookies.txt (Optionnel) :\n\n- Si vous utilisez la fonctionnalité 'Utiliser le cookie' et que vous ne fournissez pas de chaîne de cookie directe ou que vous ne parcourez pas un fichier spécifique, l'application cherchera un fichier nommé
cookies.txt dans son répertoire. \n- Format : Doit être au format de fichier de cookie Netscape.
\n- Utilisation : Permet au téléchargeur d'utiliser la session de connexion de votre navigateur pour accéder au contenu qui pourrait être derrière une connexion sur Kemono/Coomer.
\n
\n \n
\nVisite pour le premier utilisateur
\n\n- Au premier lancement (ou si réinitialisé), une boîte de dialogue de visite de bienvenue apparaît, vous guidant à travers les principales fonctionnalités. Vous pouvez la passer ou choisir de \"Ne plus jamais afficher cette visite.\"
\n
\nDe nombreux éléments de l'UI ont également des info-bulles qui apparaissent lorsque vous survolez votre souris, fournissant des conseils rapides.
\n"
}
+translations ["en"]={
+ "settings_dialog_title": "Settings",
+ "language_label": "Language:",
+ "lang_english": "English",
+ "lang_japanese": "Japanese (日本語)",
+ "theme_toggle_light": "Switch to light mode",
+ "theme_toggle_dark": "Switch to dark mode",
+ "theme_tooltip_light": "Change the application's appearance to light.",
+ "theme_tooltip_dark": "Change the application's appearance to dark.",
+ "ok_button": "OK",
+ "appearance_group_title": "Appearance",
+ "language_group_title": "Language Settings",
+ "creator_post_url_label": "🔗 Creator/Post Kemono URL:",
+ "download_location_label": "📁 Download Location:",
+ "filter_by_character_label": "🎯 Filter by Character(s) (comma-separated):",
+ "skip_with_words_label": "🚫 Skip with words (comma-separated):",
+ "remove_words_from_name_label": "✂️ Remove words from name:",
+ "filter_all_radio": "All",
+ "filter_images_radio": "Images/GIFs",
+ "filter_videos_radio": "Videos",
+ "filter_archives_radio": "📦 Only Archives",
+ "filter_links_radio": "🔗 Only Links",
+ "filter_audio_radio": "🎧 Only Audio",
+ "favorite_mode_checkbox_label": "⭐ Favorite Mode",
+ "browse_button_text": "Browse...",
+ "char_filter_scope_files_text": "Filter: Files",
+ "char_filter_scope_files_tooltip": "Current scope: Files\n\nFilters individual files by name. A post is kept if any file matches.\nOnly the matching files from that post are downloaded.\nExample: Filter 'Tifa'. File 'Tifa_artwork.jpg' matches and is downloaded.\nFolder Naming: Uses the character from the matching filename.\n\nClick to switch to: Both",
+ "char_filter_scope_title_text": "Filter: Title",
+ "char_filter_scope_title_tooltip": "Current scope: Title\n\nFilters entire posts by their title. All files from a matching post are downloaded.\nExample: Filter 'Aerith'. Post titled 'Aerith's Garden' matches; all its files are downloaded.\nFolder Naming: Uses the character from the matching post title.\n\nClick to switch to: Files",
+ "char_filter_scope_both_text": "Filter: Both",
+ "char_filter_scope_both_tooltip": "Current scope: Both (Title then Files)\n\n1. Checks the post title: If it matches, all files in the post are downloaded.\n2. If the title doesn't match, checks filenames: If a file matches, only that file is downloaded.\nExample: Filter 'Cloud'.\n - Post 'Cloud Strife' (title match) -> all files are downloaded.\n - Post 'Motorcycle Chase' with 'Cloud_fenrir.jpg' (file match) -> only 'Cloud_fenrir.jpg' is downloaded.\nFolder Naming: Prioritizes title match, then file match.\n\nClick to switch to: Comments",
+ "char_filter_scope_comments_text": "Filter: Comments (Beta)",
+ "char_filter_scope_comments_tooltip": "Current scope: Comments (Beta - Files first, then Comments as fallback)\n\n1. Checks filenames: If a file in the post matches the filter, the entire post is downloaded. Comments are NOT checked for this filter term.\n2. If no file matches, THEN checks post comments: If a comment matches, the entire post is downloaded.\nExample: Filter 'Barret'.\n - Post A: Files 'Barret_gunarm.jpg', 'other.png'. File 'Barret_gunarm.jpg' matches. All files from Post A are downloaded. Comments are not checked for 'Barret'.\n - Post B: Files 'dyne.jpg', 'weapon.gif'. Comments: '...a drawing of Barret Wallace...'. No file match for 'Barret'. Comment matches. All files from Post B are downloaded.\nFolder Naming: Prioritizes character from file match, then comment match.\n\nClick to switch to: Title",
+ "char_filter_scope_unknown_text": "Filter: Unknown",
+ "char_filter_scope_unknown_tooltip": "Current Scope: Unknown\n\nThe character filter scope is in an unknown state. Please cycle or reset.\n\nClick to switch to: Title",
+ "skip_words_input_tooltip": "Enter words, comma-separated, to skip downloading certain content (e.g., WIP, sketch, preview).\n\nThe 'Scope: [Type]' button next to this input changes how this filter applies:\n- Scope: Files: Skips individual files if their names contain any of these words.\n- Scope: Posts: Skips entire posts if their titles contain any of these words.\n- Scope: Both: Applies both (post title first, then individual files if post title is OK).",
+ "remove_words_input_tooltip": "Enter words, comma-separated, to remove from downloaded filenames (case-insensitive).\nUseful for cleaning up common prefixes/suffixes.\nExample: patreon, kemono, [HD], _final",
+ "skip_scope_files_text": "Scope: Files",
+ "skip_scope_files_tooltip": "Current Skip Scope: Files\n\nSkips individual files if their names contain any of the 'Skip Words'.\nExample: Skip Words \"WIP, sketch\".\n- File \"art_WIP.jpg\" -> SKIPPED.\n- File \"final_art.png\" -> DOWNLOADED (if other conditions met).\n\nThe post is still processed for other non-skipped files.\nClick to switch to: Both",
+ "skip_scope_posts_text": "Scope: Posts",
+ "skip_scope_posts_tooltip": "Current Skip Scope: Posts\n\nSkips entire posts if their titles contain any of the 'Skip Words'.\nAll files from a skipped post are ignored.\nExample: Skip Words \"preview, announcement\".\n- Post \"Exciting Announcement!\" -> SKIPPED.\n- Post \"Finished Artwork\" -> PROCESSED (if other conditions met).\n\nClick to switch to: Files",
+ "skip_scope_both_text": "Scope: Both",
+ "skip_scope_both_tooltip": "Current Skip Scope: Both (Posts then Files)\n\n1. Checks the post title: If the title contains a skip word, the entire post is SKIPPED.\n2. If post title is OK, then checks individual filenames: If a filename contains a skip word, only that file is SKIPPED.\nExample: Skip Words \"WIP, sketch\".\n- Post \"Sketches and WIPs\" (title match) -> ENTIRE POST SKIPPED.\n- Post \"Art Update\" (title OK) with files:\n - \"character_WIP.jpg\" (file match) -> SKIPPED.\n - \"final_scene.png\" (file OK) -> DOWNLOADED.\n\nClick to switch to: Posts",
+ "skip_scope_unknown_text": "Scope: Unknown",
+ "skip_scope_unknown_tooltip": "Current Skip Scope: Unknown\n\nThe skip words scope is in an unknown state. Please cycle or reset.\n\nClick to switch to: Posts",
+ "language_change_title": "Language Changed",
+ "language_change_message": "The language has been changed. A restart is required for all changes to take full effect.",
+ "language_change_informative": "Do you want to restart the application now?",
+ "restart_now_button": "Restart Now",
+ "skip_zip_checkbox_label": "Skip .zip",
+ "skip_rar_checkbox_label": "Skip .rar",
+ "download_thumbnails_checkbox_label": "Download thumbnails only",
+ "scan_content_images_checkbox_label": "Scan content for images",
+ "compress_images_checkbox_label": "Compress to WebP",
+ "separate_folders_checkbox_label": "Separate folders by Name/Title",
+ "subfolder_per_post_checkbox_label": "Subfolder per post",
+ "use_cookie_checkbox_label": "Use cookie",
+ "use_multithreading_checkbox_base_label": "Use multithreading",
+ "show_external_links_checkbox_label": "Show external links in log",
+ "manga_comic_mode_checkbox_label": "Manga/Comic Mode",
+ "threads_label": "Threads:",
+ "start_download_button_text": "⬇️ Start Download",
+ "start_download_button_tooltip": "Click to start the download or link extraction process with the current settings.",
+ "extract_links_button_text": "🔗 Extract Links",
+ "pause_download_button_text": "⏸️ Pause Download",
+ "pause_download_button_tooltip": "Click to pause the currently running download process.",
+ "resume_download_button_text": "▶️ Resume Download",
+ "resume_download_button_tooltip": "Click to resume the download.",
+ "cancel_button_text": "❌ Cancel & Reset UI",
+ "cancel_button_tooltip": "Click to cancel the current download/extraction process and reset the UI fields (keeping URL and directory).",
+ "error_button_text": "Error",
+ "error_button_tooltip": "View files skipped due to errors and optionally retry them.",
+ "cancel_retry_button_text": "❌ Cancel Retry",
+ "known_chars_label_text": "🎭 Known Series/Characters (for folder names):",
+ "open_known_txt_button_text": "Open Known.txt",
+ "known_chars_list_tooltip": "This list contains names used for automatic folder creation when 'Separate Folders' is on\nand no specific 'Filter by Character(s)' is provided or matches a post.\nAdd the names of series, games, or characters you frequently download.",
+ "open_known_txt_button_tooltip": "Open the 'Known.txt' file in your default text editor.\nThe file is located in the application's directory.",
+ "add_char_button_text": "➕ Add",
+ "add_char_button_tooltip": "Add the name from the input field to the 'Known Series/Characters' list.",
+ "add_to_filter_button_text": "⤵️ Add to Filter",
+ "add_to_filter_button_tooltip": "Select names from the 'Known Series/Characters' list to add them to the 'Filter by Character(s)' field above.",
+ "delete_char_button_text": "🗑️ Delete Selected",
+ "delete_char_button_tooltip": "Delete the selected name(s) from the 'Known Series/Characters' list.",
+ "progress_log_label_text": "📜 Progress Log:",
+ "radio_all_tooltip": "Download all file types found in posts.",
+ "radio_images_tooltip": "Download only common image formats (JPG, PNG, GIF, WEBP, etc.).",
+ "radio_videos_tooltip": "Download only common video formats (MP4, MKV, WEBM, MOV, etc.).",
+ "radio_only_archives_tooltip": "Exclusively download .zip and .rar files. Other file-specific options are disabled.",
+ "radio_only_audio_tooltip": "Download only common audio formats (MP3, WAV, FLAC, etc.).",
+ "radio_only_links_tooltip": "Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled.",
+ "favorite_mode_checkbox_tooltip": "Enable Favorite Mode to browse and download from saved artists/posts.\nThis will replace the URL input field with Favorite selection buttons.",
+ "skip_zip_checkbox_tooltip": "If checked, .zip archive files will not be downloaded.\n(Disabled if 'Archives Only' is selected).",
+ "skip_rar_checkbox_tooltip": "If checked, .rar archive files will not be downloaded.\n(Disabled if 'Archives Only' is selected).",
+ "download_thumbnails_checkbox_tooltip": "Downloads the small preview images from the API instead of full-size files (if available).\nIf 'Scan post content for image URLs' is also checked, this mode will *only* download images found by content scanning (ignoring API thumbnails).",
+ "scan_content_images_checkbox_tooltip": "If checked, the downloader will scan the HTML content of posts for image URLs (from
tags or direct links).\nThis includes resolving relative paths from
tags to full URLs.\nRelative paths in
tags (e.g., /data/image.jpg) will be resolved to full URLs.\nUseful for cases where images are in the post description but not in the API's file/attachment list.",
+ "compress_images_checkbox_tooltip": "Compress images > 1.5MB to WebP format (requires Pillow).",
+ "use_subfolders_checkbox_tooltip": "Create subfolders based on the 'Filter by Character(s)' input or post titles.\nUses the 'Known Series/Characters' list as a fallback for folder names if no specific filter matches.\nEnables the 'Filter by Character(s)' and 'Custom Folder Name' input for single posts.",
+ "use_subfolder_per_post_checkbox_tooltip": "Creates a subfolder for each post. If 'Separate Folders' is also on, it goes inside the character/title folder.",
+ "use_cookie_checkbox_tooltip": "If checked, will attempt to use cookies from 'cookies.txt' (Netscape format)\nin the application directory for requests.\nUseful for accessing content that requires a login on Kemono/Coomer.",
+ "cookie_text_input_tooltip": "Enter your cookie string directly.\nThis will be used if 'Use cookie' is checked AND 'cookies.txt' is not found or this field is not empty.\nThe format depends on how the backend will parse it (e.g., 'name1=value1; name2=value2').",
+ "use_multithreading_checkbox_tooltip": "Enables concurrent operations. See 'Threads' field for details.",
+ "thread_count_input_tooltip": "Number of concurrent operations.\n- Single Post: Concurrent file downloads (1-10 recommended).\n- Creator Feed URL: Number of posts to process simultaneously (1-200 recommended).\n Files from each post are downloaded one-by-one by its worker.\nIf 'Use multithreading' is unchecked, 1 thread is used.",
+ "external_links_checkbox_tooltip": "If checked, a secondary log panel appears below the main log to display external links found in post descriptions.\n(Disabled if 'Links Only' or 'Archives Only' mode is active).",
+ "manga_mode_checkbox_tooltip": "Downloads posts from oldest to newest and renames files based on the post title (for creator feeds only).",
+ "multipart_on_button_text": "Multi-part: ON",
+ "multipart_on_button_tooltip": "Multipart Downloading: ON\n\nEnables downloading large files in several segments simultaneously.\n- May speed up single large file downloads (e.g., videos).\n- Can increase CPU/network usage.\n- For feeds with many small files, this might not offer speed benefits and could make the UI/log busy.\n- If multipart fails, it retries as a single stream.\n\nClick to disable.",
+ "multipart_off_button_text": "Multi-part: OFF",
+ "multipart_off_button_tooltip": "Multipart Downloading: OFF\n\nAll files are downloaded using a single stream.\n- Stable and works well for most scenarios, especially many small files.\n- Large files downloaded sequentially.\n\nClick to enable (see warning).",
+ "reset_button_text": "🔄 Reset",
+ "reset_button_tooltip": "Reset all inputs and logs to their default state (only when app is idle).",
+ "progress_idle_text": "Progress: Idle",
+ "missed_character_log_label_text": "🚫 Missed Character Log:",
+ "creator_popup_title": "Creator Selection",
+ "creator_popup_search_placeholder": "Search by name, service, or paste creator URL...",
+ "creator_popup_add_selected_button": "Add Selected",
+ "creator_popup_scope_characters_button": "Scope: Characters",
+ "creator_popup_scope_creators_button": "Scope: Creators",
+ "favorite_artists_button_text": "🖼️ Favorite Artists",
+ "favorite_artists_button_tooltip": "Browse and download from your favorite artists on Kemono.su/Coomer.su.",
+ "favorite_posts_button_text": "📄 Favorite Posts",
+ "favorite_posts_button_tooltip": "Browse and download your favorite posts from Kemono.su/Coomer.su.",
+ "favorite_scope_selected_location_text": "Scope: Selected Location",
+ "favorite_scope_selected_location_tooltip": "Current Favorite Download Scope: Selected Location\n\nAll selected favorite artists/posts will be downloaded to the main 'Download Location' specified in the UI.\nFilters (character, skip words, file type) will apply globally to all content.\n\nClick to change to: Artist Folders",
+ "favorite_scope_artist_folders_text": "Scope: Artist Folders",
+ "favorite_scope_artist_folders_tooltip": "Current Favorite Download Scope: Artist Folders\n\nFor each selected favorite artist/post, a new subfolder (named after the artist) will be created inside the main 'Download Location'.\nThat artist's/post's content will be downloaded into its specific subfolder.\nFilters (character, skip words, file type) will apply *within* each artist's folder.\n\nClick to change to: Selected Location",
+ "favorite_scope_unknown_text": "Scope: Unknown",
+ "favorite_scope_unknown_tooltip": "Favorite download scope is unknown. Click to cycle.",
+ "manga_style_post_title_text": "Name: Post Title",
+ "manga_style_original_file_text": "Name: Original File",
+ "manga_style_date_based_text": "Name: Date Based",
+ "manga_style_title_global_num_text": "Name: Title+G.Num",
+ "manga_style_unknown_text": "Name: Unknown Style",
+ "fav_artists_dialog_title": "Favorite Artists",
+ "fav_artists_loading_status": "Loading favorite artists...",
+ "fav_artists_search_placeholder": "Search artists...",
+ "fav_artists_select_all_button": "Select All",
+ "fav_artists_deselect_all_button": "Deselect All",
+ "fav_artists_download_selected_button": "Download Selected",
+ "fav_artists_cancel_button": "Cancel",
+ "fav_artists_loading_from_source_status": "⏳ Loading favorites from {source_name}...",
+ "fav_artists_found_status": "{count} favorite artist(s) found in total.",
+ "fav_artists_none_found_status": "No favorite artists found on Kemono.su or Coomer.su.",
+ "fav_artists_failed_status": "Failed to retrieve favorites.",
+ "fav_artists_cookies_required_status": "Error: Cookies enabled but could not be loaded for any source.",
+ "fav_artists_no_favorites_after_processing": "No favorite artists found after processing.",
+ "fav_artists_no_selection_title": "No Selection",
+ "fav_artists_no_selection_message": "Please select at least one artist to download.",
+ "fav_posts_dialog_title": "Favorite Posts",
+ "fav_posts_loading_status": "Loading favorite posts...",
+ "fav_posts_search_placeholder": "Search posts (title, creator, ID, service)...",
+ "fav_posts_select_all_button": "Select All",
+ "fav_posts_deselect_all_button": "Deselect All",
+ "fav_posts_download_selected_button": "Download Selected",
+ "fav_posts_cancel_button": "Cancel",
+ "fav_posts_cookies_required_error": "Error: Cookies are required for favorite posts but could not be loaded.",
+ "fav_posts_auth_failed_title": "Authorization Failed (Posts)",
+ "fav_posts_auth_failed_message": "Could not retrieve favorites{domain_specific_part} due to an authorization error:\n\n{error_message}\n\nThis usually means your cookies are missing, invalid, or expired for the site. Please check your cookie setup.",
+ "fav_posts_fetch_error_title": "Fetch Error",
+ "fav_posts_fetch_error_message": "Error while fetching favorites from {domain}{error_message_part}",
+ "fav_posts_no_posts_found_status": "No favorite posts found.",
+ "fav_posts_found_status": "{count} favorite post(s) found.",
+ "fav_posts_display_error_status": "Error displaying posts: {error}",
+ "fav_posts_ui_error_title": "UI Error",
+ "fav_posts_ui_error_message": "Could not display favorite posts: {error}",
+ "fav_posts_auth_failed_message_generic": "Could not retrieve favorites{domain_specific_part} due to an authorization error. This usually means your cookies are missing, invalid, or expired for the site. Please check your cookie setup.",
+ "key_fetching_fav_post_list_init": "Fetching favorite post list...",
+ "key_fetching_from_source_kemono_su": "Fetching favorites from Kemono.su...",
+ "key_fetching_from_source_coomer_su": "Fetching favorites from Coomer.su...",
+ "fav_posts_fetch_cancelled_status": "Favorite post fetching cancelled.",
+ "known_names_filter_dialog_title": "Add Known Names to Filter",
+ "known_names_filter_search_placeholder": "Search names...",
+ "known_names_filter_select_all_button": "Select All",
+ "known_names_filter_deselect_all_button": "Deselect All",
+ "known_names_filter_add_selected_button": "Add Selected",
+ "error_files_dialog_title": "Files Skipped Due to Errors",
+ "error_files_no_errors_label": "No files were logged as skipped due to errors in the last session or after retries.",
+ "error_files_found_label": "The following {count} file(s) were skipped due to download errors:",
+ "error_files_select_all_button": "Select All",
+ "error_files_retry_selected_button": "Retry Selected",
+ "error_files_export_urls_button": "Export URLs to .txt",
+ "error_files_no_selection_retry_message": "Please select at least one file to retry.",
+ "error_files_no_errors_export_title": "No Errors",
+ "error_files_no_errors_export_message": "There are no errored file URLs to export.",
+ "error_files_no_urls_found_export_title": "No URLs Found",
+ "error_files_no_urls_found_export_message": "Could not extract any URLs from the errored files list to export.",
+ "error_files_save_dialog_title": "Save Errored File URLs",
+ "error_files_export_success_title": "Export Successful",
+ "error_files_export_success_message": "{count} entries successfully exported to:\n{filepath}",
+ "error_files_export_error_title": "Export Error",
+ "error_files_export_error_message": "Could not export file links: {error}",
+ "export_options_dialog_title": "Export Options",
+ "export_options_description_label": "Choose the export format for errored file links:",
+ "export_options_radio_link_only": "Link Per Line (URL only)",
+ "export_options_radio_link_only_tooltip": "Exports only the direct download URL for each failed file, one URL per line.",
+ "export_options_radio_with_details": "Export with Details (URL [Post, File Info])",
+ "export_options_radio_with_details_tooltip": "Exports the URL followed by details like the post title, post ID, and original filename in brackets.",
+ "export_options_export_button": "Export",
+ "no_errors_logged_title": "No Errors Logged",
+ "no_errors_logged_message": "No files were logged as skipped due to errors in the last session or after retries.",
+ "progress_initializing_text": "Progress: Initializing...",
+ "progress_posts_text": "Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)",
+ "progress_processing_post_text": "Progress: Processing post {processed_posts}...",
+ "progress_starting_text": "Progress: Starting...",
+ "downloading_file_known_size_text": "Downloading '{filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)",
+ "downloading_file_unknown_size_text": "Downloading '{filename}' ({downloaded_mb:.1f}MB)",
+ "downloading_multipart_text": "DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts} parts @ {speed:.2f} MB/s)",
+ "downloading_multipart_initializing_text": "File: {filename} - Initializing parts...",
+ "status_completed": "Completed",
+ "status_cancelled_by_user": "Cancelled by user",
+ "files_downloaded_label": "downloaded",
+ "files_skipped_label": "skipped",
+ "retry_finished_text": "Retry finished",
+ "succeeded_text": "Succeeded",
+ "failed_text": "Failed",
+ "ready_for_new_task_text": "Ready for new task.",
+ "fav_mode_active_label_text": "⭐ Favorite Mode is active. Please select filters below before choosing your favorite artists/posts. Select an action below.",
+ "export_links_button_text": "Export Links",
+ "download_extracted_links_button_text": "Download",
+ "download_selected_button_text": "Download Selected",
+ "link_input_placeholder_text": "e.g., https://kemono.su/patreon/user/12345 or .../post/98765",
+ "link_input_tooltip_text": "Enter the full URL of a Kemono/Coomer creator page or a specific post.\nExample (Creator): https://kemono.su/patreon/user/12345\nExample (Post): https://kemono.su/patreon/user/12345/post/98765",
+ "dir_input_placeholder_text": "Select the folder where downloads will be saved",
+ "dir_input_tooltip_text": "Enter or browse to the main folder where all downloaded content will be saved.\nThis is required unless 'Links Only' mode is selected.",
+ "character_input_placeholder_text": "e.g., Tifa, Aerith, (Cloud, Zack)",
+ "custom_folder_input_placeholder_text": "Optional: Save this post to a specific folder",
+ "custom_folder_input_tooltip_text": "If you are downloading a single post URL AND 'Separate folders by Name/Title' is enabled,\nyou can enter a custom name here for this post's download folder.\nExample: My Favorite Scene",
+ "skip_words_input_placeholder_text": "e.g., WM, WIP, sketch, preview",
+ "remove_from_filename_input_placeholder_text": "e.g., patreon, HD",
+ "cookie_text_input_placeholder_no_file_selected_text": "Cookie string (if no cookies.txt is selected)",
+ "cookie_text_input_placeholder_with_file_selected_text": "Using selected cookie file (see Browse...)",
+ "character_search_input_placeholder_text": "Search characters...",
+ "character_search_input_tooltip_text": "Type here to filter the list of known series/characters below.",
+ "new_char_input_placeholder_text": "Add new series/character name",
+ "new_char_input_tooltip_text": "Enter a new series, game, or character name to add to the list above.",
+ "link_search_input_placeholder_text": "Search links...",
+ "link_search_input_tooltip_text": "In 'Links Only' mode, type here to filter the displayed links by text, URL, or platform.",
+ "manga_date_prefix_input_placeholder_text": "Prefix for Manga filenames",
+ "manga_date_prefix_input_tooltip_text": "Optional prefix for 'Date Based' or 'Original File' manga filenames (e.g., 'Series Name').\nIf empty, files will be named according to the style without a prefix.",
+ "log_display_mode_links_view_text": "🔗 Links View",
+ "log_display_mode_progress_view_text": "⬇️ Progress View",
+ "download_external_links_dialog_title": "Download Selected External Links",
+ "select_all_button_text": "Select All",
+ "deselect_all_button_text": "Deselect All",
+ "cookie_browse_button_tooltip": "Browse for a cookie file (Netscape format, usually cookies.txt).\nThis will be used if 'Use cookie' is checked and the text field above is empty.",
+ "page_range_label_text": "Page Range:",
+ "start_page_input_placeholder": "Start",
+ "start_page_input_tooltip": "For creator URLs: Specify the starting page number for the download (e.g., 1, 2, 3).\nLeave empty or set to 1 to start from the first page.\nDisabled for single post URLs or in Manga/Comic Mode.",
+ "page_range_to_label_text": "to",
+ "end_page_input_placeholder": "End",
+ "end_page_input_tooltip": "For creator URLs: Specify the ending page number for the download (e.g., 5, 10).\nLeave empty to download all pages from the start page.\nDisabled for single post URLs or in Manga/Comic Mode.",
+ "known_names_help_button_tooltip_text": "Open the application feature guide.",
+ "future_settings_button_tooltip_text": "Open application settings (Theme, Language, etc.).",
+ "link_search_button_tooltip_text": "Filter displayed links",
+ "confirm_add_all_dialog_title": "Confirm Adding New Names",
+ "confirm_add_all_info_label": "The following new names/groups from your 'Filter by Character(s)' input are not in 'Known.txt'.\nAdding them can improve folder organization for future downloads.\n\nPlease review the list and choose an action:",
+ "confirm_add_all_select_all_button": "Select All",
+ "confirm_add_all_deselect_all_button": "Deselect All",
+ "confirm_add_all_add_selected_button": "Add Selected to Known.txt",
+ "confirm_add_all_skip_adding_button": "Skip Adding These",
+ "confirm_add_all_cancel_download_button": "Cancel Download",
+ "cookie_help_dialog_title": "Cookie File Instructions",
+ "cookie_help_instruction_intro": "To use cookies, you typically need a cookies.txt file from your browser.
",
+ "cookie_help_how_to_get_title": "How to get cookies.txt:
",
+ "cookie_help_step1_extension_intro": "Install the 'Get cookies.txt LOCALLY' extension for your Chrome-based browser:
Get cookies.txt LOCALLY on Chrome Web Store",
+ "cookie_help_step2_login": "Go to the website (e.g., kemono.su or coomer.su) and log in if necessary.",
+ "cookie_help_step3_click_icon": "Click the extension icon in your browser's toolbar.",
+ "cookie_help_step4_export": "Click an 'Export' button (e.g., \"Export As\", \"Export cookies.txt\" - exact wording may vary by extension version).",
+ "cookie_help_step5_save_file": "Save the downloaded cookies.txt file to your computer.",
+ "cookie_help_step6_app_intro": "In this application:",
+ "cookie_help_step6a_checkbox": "- Make sure the 'Use cookie' box is checked.
",
+ "cookie_help_step6b_browse": "- Click the 'Browse...' button next to the cookie text field.
",
+ "cookie_help_step6c_select": "- Select the
cookies.txt file you just saved.
",
+ "cookie_help_alternative_paste": "Alternatively, some extensions may let you copy the cookie string directly. If so, you can paste that into the text field instead of browsing for a file.
",
+ "cookie_help_proceed_without_button": "Download without cookies",
+ "cookie_help_cancel_download_button": "Cancel Download",
+ "character_input_tooltip": "Enter character names (comma-separated). Supports advanced grouping and affects folder naming if 'Separate Folders' is enabled.\n\nExamples:\n- Nami → Matches 'Nami', creates 'Nami' folder.\n- (Ulti, Vivi) → Matches either, folder 'Ulti Vivi', adds both to Known.txt separately.\n- (Boa, Hancock)~ → Matches either, folder 'Boa Hancock', adds as one group to Known.txt.\n\nNames are treated as aliases for matching.\n\nFilter Modes (button cycles):\n- Files: Filters by filename.\n- Title: Filters by post title.\n- Both: Title first, then filename.\n- Comments (Beta): Filename first, then post comments.",
+ "tour_dialog_title": "Welcome to Kemono Downloader!",
+ "tour_dialog_never_show_checkbox": "Never show this tour again",
+ "tour_dialog_skip_button": "Skip Tour",
+ "tour_dialog_back_button": "Back",
+ "tour_dialog_next_button": "Next",
+ "tour_dialog_finish_button": "Finish",
+ "tour_dialog_step1_title": "👋 Welcome!",
+ "tour_dialog_step1_content": "Hello! This quick tour will guide you through the main features of Kemono Downloader, including recent updates like enhanced filtering, manga mode improvements, and cookie handling.\n\n- My goal is to help you easily download content from Kemono and Coomer.
\n- 🎨 Creator Selection Button: Next to the URL input, click the palette icon to open a dialog. Browse and select creators from your
creators.json file to quickly add their names to the URL input.
\n- Important Tip: App '(Not Responding)'?
\nAfter clicking 'Start Download', especially for large creator feeds or with many threads, the app might temporarily show '(Not Responding)'. Your operating system (Windows, macOS, Linux) might even suggest you 'End Process' or 'Force Quit'.
\nPlease be patient! The app is often working hard in the background. Before force-closing, try checking your chosen 'Download Location' in your file explorer. If you see new folders being created or files appearing, it means the download is progressing correctly. Give it some time to become responsive again.
\n- Use the Next and Back buttons to navigate.
\n- Many options have tooltips if you hover over them for more details.
\n- Click Skip Tour to close this guide at any time.
\n- Check 'Never show this tour again' if you don't want to see this on future startups.
\n
",
+ "tour_dialog_step2_title": "① Getting Started",
+ "tour_dialog_step2_content": "Let's start with the download basics:\n\n- 🔗 Creator/Post Kemono URL:
\nPaste the full web address (URL) of a creator's page (e.g., https://kemono.su/patreon/user/12345) \nor a specific post (e.g., .../post/98765).
\nor a Coomer creator (e.g., https://coomer.su/onlyfans/user/artistname)
\n- 📁 Download Location:
\nClick 'Browse...' to choose a folder on your computer where all downloaded files will be saved. \nThis is required unless you are using 'Links Only' mode.
\n- 📄 Page Range (Creator URLs only):
\nIf downloading from a creator's page, you can specify a range of pages to grab (e.g., pages 2 to 5). \nLeave blank for all pages. This is disabled for single post URLs or when Manga/Comic Mode is active. \n
",
+ "tour_dialog_step3_title": "② Filtering Downloads",
+ "tour_dialog_step3_content": "Refine what you download with these filters (most are disabled in 'Links Only' or 'Archives Only' modes):\n\n- 🎯 Filter by Character(s):
\nEnter character names, separated by commas (e.g., Tifa, Aerith). Group aliases for a combined folder name: (alias1, alias2, alias3) becomes the folder 'alias1 alias2 alias3' (after cleanup). All names in the group are used as aliases for matching.
\nThe 'Filter: [Type]' button (next to this input) changes how this filter applies:\n- Filter: Files: Checks individual filenames. A post is kept if any file matches; only the matching files are downloaded. Folder naming uses the character from the matching filename (if 'Separate Folders' is on).
\n- Filter: Title: Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title.
\n- ⤵️ Add to Filter Button (Known Names): Next to the 'Add' button for Known Names (see Step 5), this opens a popup. Select names from your
Known.txt list via checkboxes (with a search bar) to quickly add them to the 'Filter by Character(s)' field. Grouped names like (Boa, Hancock) from Known.txt will be added as (Boa, Hancock)~ to the filter.
\n- Filter: Both: Checks the post title first. If it matches, all files are downloaded. If not, it then checks filenames, and only matching files are downloaded. Folder naming prioritizes the title match, then the file match.
\n- Filter: Comments (Beta): Checks filenames first. If a file matches, all files in the post are downloaded. If no file match, it then checks post comments. If a comment matches, all files are downloaded. (Uses more API requests). Folder naming prioritizes the file match, then the comment match.
\nThis filter also influences folder naming if 'Separate folders by Name/Title' is on.
\n- 🚫 Skip with words:
\nEnter words, comma-separated (e.g., WIP, sketch, preview). \nThe 'Scope: [Type]' button (next to this input) changes how this filter applies:\n- Scope: Files: Skips files if their names contain any of these words.
\n- Scope: Posts: Skips entire posts if their titles contain any of these words.
\n- Scope: Both: Applies both file and post title skipping (post first, then files).
\n- Filter Files (Radio Buttons): Choose what to download:\n
\n- All: Downloads all file types found.
\n- Images/GIFs: Only common image formats and GIFs.
\n- Videos: Only common video formats.
\n- 📦 Only Archives: Exclusively downloads .zip and .rar files. When this is selected, the 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show external links' is also disabled.
\n- 🎧 Only Audio: Only common audio formats (MP3, WAV, FLAC, etc.).
\n- 🔗 Only Links: Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show external links' are disabled.
\n
\n
",
+ "tour_dialog_step4_title": "③ Favorite Mode (Alternate Downloading)",
+ "tour_dialog_step4_content": "The app offers a 'Favorite Mode' for downloading content from artists you have favorited on Kemono.su.\n\n- ⭐ Favorite Mode Checkbox:
\nLocated next to the '🔗 Only Links' radio button. Check this box to enable Favorite Mode.
\n- What Happens in Favorite Mode:\n
- The '🔗 Creator/Post Kemono URL' input area is replaced with a message indicating Favorite Mode is active.
\n- The standard 'Start Download', 'Pause', 'Cancel' buttons are replaced with '🖼️ Favorite Artists' and '📄 Favorite Posts' buttons (Note: 'Favorite Posts' is planned for the future).
\n- The '🍪 Use cookie' option is automatically enabled and locked, as cookies are required to fetch your favorites.
\n- 🖼️ Favorite Artists Button:
\nClick this to open a dialog listing your favorite artists from Kemono.su. You can select one or more artists to download.
\n- Favorite Download Scope (Button):
\nThis button (next to 'Favorite Posts') controls where selected favorites are downloaded:\n- Scope: Selected Location: All selected artists are downloaded into the main 'Download Location' you set. Filters apply globally.
\n- Scope: Artist Folders: A subfolder (named after the artist) is created in your main 'Download Location' for each selected artist. That artist's content goes into their specific folder. Filters apply within each artist's folder.
\n- Filters in Favorite Mode:
\nThe 'Filter by Character(s)', 'Skip with words', and 'Filter Files' options still apply to the content downloaded from your selected favorite artists. \n
",
+ "tour_dialog_step5_title": "④ Refining Downloads",
+ "tour_dialog_step5_content": "More options to customize your downloads:\n\n- Skip .zip / Skip .rar: Check these to avoid downloading these archive file types. \n(Note: These are disabled and ignored if '📦 Only Archives' filter mode is selected).
\n- ✂️ Remove words from name:
\nEnter words, comma-separated (e.g., patreon, [HD]), to be removed from downloaded filenames (case-insensitive).
\n- Download thumbnails only: Downloads the small preview images instead of full-size files (if available).
\n- Compress large images: If the 'Pillow' library is installed, images over 1.5MB will be converted to WebP format if the WebP version is significantly smaller.
\n- 🗄️ Custom Folder Name (Single Post Only):
\nIf you are downloading a specific post URL AND 'Separate folders by Name/Title' is enabled, \nyou can enter a custom name here for that post's download folder.
\n- 🍪 Use cookie: Check this to use cookies for requests. You can either:\n
- Enter a cookie string directly into the text field (e.g., name1=value1; name2=value2).
\n- Click 'Browse...' to select a cookies.txt file (Netscape format). The path will appear in the text field.
\nThis is useful for accessing content that requires a login. The text field takes priority if filled. \nIf 'Use cookie' is checked but both the text field and browsed file are empty, it will try to load 'cookies.txt' from the app's directory. \n
",
+ "tour_dialog_step6_title": "⑤ Organization & Performance",
+ "tour_dialog_step6_content": "Organize your downloads and manage performance:\n\n- ⚙️ Separate folders by Name/Title: Creates subfolders based on the 'Filter by Character(s)' input or post titles (can use the Known.txt list as a fallback for folder names).
\n- Subfolder per post: If 'Separate Folders' is on, this creates an additional subfolder for each individual post inside the main character/title folder.
\n- 🚀 Use multithreading (Threads): Enables faster operations. The number in the 'Threads' input means:\n
- For Creator Feeds: Number of posts to process simultaneously. Files from each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).
\n- For Single Post URLs: Number of files to download simultaneously from that single post.
\nIf unchecked, 1 thread is used. High thread counts (e.g., >40) may show a warning.
\n- Multipart Download Toggle (top-right of log area):
\nThe 'Multi-part: [ON/OFF]' button enables/disables multi-segment downloads for individual large files. \n- ON: Can speed up large file downloads (e.g., videos) but may increase UI stutter or log spam with many small files. A warning will appear on activation. If a multipart download fails, it retries as a single stream.
\n- OFF (Default): Files are downloaded in a single stream.
\nThis is disabled if 'Links Only' or 'Archives Only' mode is active.
\n- 📖 Manga/Comic Mode (Creator URLs only): Designed for sequential content.\n
\n- Downloads posts from oldest to newest.
\n- The 'Page Range' input is disabled as all posts are fetched.
\n- A filename style toggle button (e.g., 'Name: Post Title') appears at the top-right of the log area when this mode is active for a creator feed. Click it to cycle between naming styles:\n
\n- Name: Post Title (Default): The first file in a post is named after the cleaned post title (e.g., 'My Chapter 1.jpg'). Subsequent files in the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics.
\n- Name: Original File: All files attempt to keep their original filenames. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_OriginalFile.jpg'.
\n- Name: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the cleaned post title as a prefix, followed by a global counter. E.g.: Post 'Chapter 1' (2 files) -> 'Chapter 1_001.jpg', 'Chapter 1_002.png'. The next post, 'Chapter 2' (1 file), would continue the numbering -> 'Chapter 2_003.jpg'. Multithreading for post processing is automatically disabled for this style to ensure correct global numbering.
\n- Name: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on the publish order of the posts. An optional prefix (e.g., 'MySeries_') can be entered in the input field that appears next to the style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style.
\n
\n
\n- For best results with the 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
\n
\n- 🎭 Known.txt for Smart Folder Organization:
\nKnown.txt (in the app directory) allows fine-grained control over automatic folder organization when 'Separate folders by Name/Title' is on.\n\n- How it works: Each line in
Known.txt is an entry. \n- A simple line like
My Awesome Series means matching content will go into a folder named \"My Awesome Series\".
\n- A grouped line like
(Character A, Char A, Alt Name A) means content matching \"Character A\", \"Char A\", OR \"Alt Name A\" will ALL go into a single folder named \"Character A Char A Alt Name A\" (after cleanup). All terms in the parentheses become aliases for that folder.
\n- Smart Fallback: When 'Separate folders by Name/Title' is on, and if a post doesn't match any specific 'Filter by Character(s)' entries, the downloader consults
Known.txt to find a matching master name for folder creation.
\n- User-Friendly Management: Add simple (non-grouped) names via the UI list below. For advanced editing (like creating/modifying grouped aliases), click 'Open Known.txt' to edit the file in your text editor. The app reloads it on next use or next startup.
\n
\n \n
",
+ "tour_dialog_step7_title": "⑥ Common Errors & Troubleshooting",
+ "tour_dialog_step7_content": "Sometimes downloads can run into issues. Here are some of the most common ones:\n\n- Character Input Tooltip:
\nEnter character names, comma-separated (e.g., Tifa, Aerith).
\nGroup aliases for a combined folder name: (alias1, alias2, alias3) becomes folder 'alias1 alias2 alias3'.
\nAll names in the group are used as aliases for content matching.
\nThe 'Filter: [Type]' button next to this input changes how this filter applies:
\n- Filter: Files: Checks individual filenames. Only matching files are downloaded.
\n- Filter: Title: Checks post titles. All files from a matching post are downloaded.
\n- Filter: Both: Checks post title first. If no match, then checks filenames.
\n- Filter: Comments (Beta): Checks filenames first. If no match, then checks post comments.
\nThis filter also influences folder naming if 'Separate folders by Name/Title' is enabled.
\n- 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout:
\nThese usually indicate temporary server-side problems with Kemono/Coomer. The site might be overloaded, down for maintenance, or having issues.
\nSolution: Wait a while (e.g., 30 minutes to a few hours) and try again later. Check the site directly in your browser.
\n- Connection Lost / Connection Refused / Timeout (during file download):
\nThis can happen due to your internet connection, server instability, or if the server drops the connection for a large file.
\nSolution: Check your internet. Try reducing the 'Threads' count if it's high. The app may offer to retry some failed files at the end of a session.
\n- IncompleteRead Error:
\nThe server sent less data than expected. Often a temporary network hiccup or server issue.
\nSolution: The app will often mark these files for a retry at the end of the download session.
\n- 403 Forbidden / 401 Unauthorized (less common for public posts):
\nYou may not have permission to access the content. For some paywalled or private content, using the 'Use cookie' option with valid cookies from your browser session might help. Ensure your cookies are up to date.
\n- 404 Not Found:
\nThe post or file URL is incorrect, or the content has been deleted from the site. Double-check the URL.
\n- 'No posts found' / 'Target post not found':
\nEnsure the URL is correct and the creator/post exists. If using page ranges, make sure they are valid for the creator. For very new posts, there might be a slight delay before they appear in the API.
\n- General Slowness / App '(Not Responding)':
\nAs mentioned in Step 1, if the app appears to freeze after starting, especially with large creator feeds or many threads, please give it time. It is likely processing data in the background. Reducing the thread count can sometimes improve responsiveness if this is frequent. \n
",
+ "tour_dialog_step8_title": "⑦ Logs & Final Controls",
+ "tour_dialog_step8_content": "Monitoring and Controls:\n\n- 📜 Progress Log / Extracted Links Log: Shows detailed download messages. If '🔗 Only Links' mode is active, this area displays the extracted links.
\n- Show external links in log: If checked, a secondary log panel appears below the main log to display external links found in post descriptions. (This is disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).
\n- Log Display Toggle (👁️ / 🙈 Button):
\nThis button (top-right of the log area) changes the main log view:\n- 👁️ Progress Log (Default): Shows all download activity, errors, and summaries.
\n- 🙈 Missed Character Log: Displays a list of key terms from post titles that were skipped due to your 'Filter by Character(s)' settings. Useful for identifying content you might be unintentionally missing.
\n- 🔄 Reset: Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.
\n- ⬇️ Start Download / 🔗 Extract Links / ⏸️ Pause / ❌ Cancel: These buttons control the process. 'Cancel & Reset UI' stops the current operation and performs a soft reset of the UI, keeping your URL and directory inputs. 'Pause/Resume' allows for temporary halting and continuing.
\n- If some files fail with recoverable errors (like 'IncompleteRead'), you may be prompted to retry them at the end of a session.
\n
\n
You're all set! Click 'Finish' to close the tour and start using the downloader.",
+ "help_guide_dialog_title": "Kemono Downloader - Feature Guide",
+ "help_guide_github_tooltip": "Visit the project's GitHub page (Opens in browser)",
+ "help_guide_instagram_tooltip": "Visit our Instagram page (Opens in browser)",
+ "help_guide_discord_tooltip": "Join our Discord community (Opens in browser)",
+ "help_guide_step1_title": "① Introduction & Main Inputs",
+ "help_guide_step1_content": "\nThis guide provides an overview of the features, fields, and buttons in the Kemono Downloader.
\nMain Input Area (Top-Left)
\n\n- 🔗 Creator/Post Kemono URL:\n
\n- Enter the full web address of a creator's page (e.g., https://kemono.su/patreon/user/12345) or a specific post (e.g., .../post/98765).
\n- Supports Kemono (kemono.su, kemono.party) and Coomer (coomer.su, coomer.party) URLs.
\n
\n \n- Page Range (Start to End):\n
\n- For creator URLs: Specify a range of pages to grab (e.g., pages 2 to 5). Leave blank for all pages.
\n- Disabled for single post URLs or when Manga/Comic Mode is active.
\n
\n \n- 📁 Download Location:\n
\n- Click 'Browse...' to choose a main folder on your computer where all downloaded files will be saved.
\n- This field is required unless you are using '🔗 Only Links' mode.
\n
\n \n- 🎨 Creator Selection Button (next to URL input):\n
\n- Click the palette icon (🎨) to open the 'Creator Selection' dialog.
\n- This dialog loads creators from your
creators.json file (which must be in the app directory). \n- Inside the dialog:\n
\n- Search bar: Type to filter the creator list by name or service.
\n- Creator list: Displays creators from your
creators.json. Creators you have marked as 'favorites' (in the JSON data) appear at the top. \n- Checkboxes: Select one or more creators by checking the box next to their name.
\n- 'Scope' Button (e.g., 'Scope: Characters'): This button toggles the download organization when initiating downloads from this popup:\n
- Scope: Characters: Downloads will be organized into character-named folders directly in your main 'Download Location'. Art from different creators for the same character will be grouped.
\n- Scope: Creators: Downloads will first create a creator-named folder in your main 'Download Location'. Character-named subfolders will then be created inside each creator's folder.
\n \n- 'Add Selected' Button: Clicking this will take the names of all checked creators and add them to the main '🔗 Creator/Post Kemono URL' input field, separated by commas. The dialog will then close.
\n
\n \n- This feature provides a quick way to populate the URL field for multiple creators without manually typing or pasting each URL.
\n
\n \n
",
+ "help_guide_step2_title": "② Filtering Downloads",
+ "help_guide_step2_content": "\nFiltering Downloads (Left Panel)
\n\n- 🎯 Filter by Character(s):\n
\n- Enter names, comma-separated (e.g.,
Tifa, Aerith). \n- Grouped Aliases for Shared Folder (Separate Known.txt entries):
(Vivi, Ulti, Uta).\n- Content matching \"Vivi\", \"Ulti\", OR \"Uta\" will go into a shared folder named \"Vivi Ulti Uta\" (after cleanup).
\n- If these names are new, you will be prompted to add \"Vivi\", \"Ulti\", and \"Uta\" as separate individual entries to
Known.txt. \n
\n \n- Grouped Aliases for Shared Folder (Single Known.txt entry):
(Yuffie, Sonon)~ (note the tilde ~).\n- Content matching \"Yuffie\" OR \"Sonon\" will go into a shared folder named \"Yuffie Sonon\".
\n- If new, \"Yuffie Sonon\" (with aliases Yuffie, Sonon) will be proposed to be added as a single group entry to
Known.txt. \n
\n \n- This filter influences folder naming if 'Separate folders by Name/Title' is enabled.
\n
\n \n- Filter: [Type] Button (Character Filter Scope): Cycles how 'Filter by Character(s)' applies:\n
\nFilter: Files: Checks individual filenames. A post is kept if a file matches; only matching files are downloaded. Folder naming uses the character from the matching filename. \nFilter: Title: Checks post titles. All files from a matching post are downloaded. Folder naming uses the character from the matching post title. \nFilter: Both: Checks post title first. If it matches, all files are downloaded. If not, it then checks filenames, and only matching files are downloaded. Folder naming prioritizes the title match, then the file match. \nFilter: Comments (Beta): Checks filenames first. If a file matches, all files in the post are downloaded. If no file match, it then checks post comments. If a comment matches, all files are downloaded. (Uses more API requests). Folder naming prioritizes the file match, then the comment match. \n
\n \n- 🗄️ Custom Folder Name (Single Post Only):\n
\n- Visible and usable only when downloading a specific post URL AND 'Separate folders by Name/Title' is enabled.
\n- Allows specifying a custom name for that single post's download folder.
\n
\n \n- 🚫 Skip with words:\n
- Enter words, comma-separated (e.g.,
WIP, sketch, preview) to ignore certain content.
\n \n- Scope: [Type] Button (Skip Words Scope): Cycles how 'Skip with words' applies:\n
\nScope: Files: Skips individual files if their names contain any of these words. \nScope: Posts: Skips entire posts if their titles contain any of these words. \nScope: Both: Applies both (post title first, then individual files). \n
\n \n- ✂️ Remove words from name:\n
- Enter words, comma-separated (e.g.,
patreon, [HD]), to be removed from downloaded filenames (case-insensitive).
\n \n- Filter Files (Radio Buttons): Choose what to download:\n
\nAll: Downloads all file types found. \nImages/GIFs: Only common image formats (JPG, PNG, GIF, WEBP, etc.) and GIFs. \nVideos: Only common video formats (MP4, MKV, WEBM, MOV, etc.). \n📦 Only Archives: Exclusively downloads .zip and .rar files. When this is selected, the 'Skip .zip' and 'Skip .rar' checkboxes are automatically disabled and unchecked. 'Show external links' is also disabled. \n🎧 Only Audio: Downloads only common audio formats (MP3, WAV, FLAC, M4A, OGG, etc.). Other file-specific options behave as in 'Images' or 'Videos' mode. \n🔗 Only Links: Extracts and displays external links from post descriptions instead of downloading files. Download-related options and 'Show external links' are disabled. The main download button becomes '🔗 Extract Links'. \n
\n \n
",
+ "help_guide_step3_title": "③ Download Options & Settings",
+ "help_guide_step3_content": "\nDownload Options & Settings (Left Panel)
\n\n- Skip .zip / Skip .rar: Checkboxes to avoid downloading these archive file types. (Disabled and ignored if '📦 Only Archives' filter mode is selected).
\n- Download thumbnails only: Downloads the small preview images instead of full-size files (if available).
\n- Compress large images (to WebP): If the 'Pillow' (PIL) library is installed, images over 1.5MB will be converted to WebP format if the WebP version is significantly smaller.
\n- ⚙️ Advanced Settings:\n
\n- Separate folders by Name/Title: Creates subfolders based on the 'Filter by Character(s)' input or post titles. Can use the Known.txt list as a fallback for folder names.
",
+ "help_guide_step4_title": "④ Advanced Settings (Part 1)",
+ "help_guide_step4_content": "⚙️ Advanced Settings (Continued)
\n- Subfolder per post: If 'Separate Folders' is on, this creates an additional subfolder for each individual post inside the main character/title folder.
\n- Use cookie: Check this box to use cookies for requests.\n
\n- Text Field: Enter a cookie string directly (e.g.,
name1=value1; name2=value2). \n- Browse...: Select a
cookies.txt file (Netscape format). The path will appear in the text field. \n- Priority: The text field (if filled) takes priority over a browsed file. If 'Use cookie' is checked but both are empty, it attempts to load
cookies.txt from the app's directory. \n
\n \n- Use multithreading & Threads Input:\n
\n- Enables faster operations. The number in the 'Threads' input means:\n
\n- For Creator Feeds: Number of posts to process simultaneously. Files from each post are downloaded sequentially by its worker (unless 'Date Based' manga naming is on, which forces 1 post worker).
\n- For Single Post URLs: Number of files to download simultaneously from that single post.
\n
\n \n- If unchecked, 1 thread is used. High thread counts (e.g., >40) may show a warning.
\n
\n
",
+ "help_guide_step5_title": "⑤ Advanced Settings (Part 2) & Actions",
+ "help_guide_step5_content": "⚙️ Advanced Settings (Continued)
\n- Show external links in log: If checked, a secondary log panel appears below the main log to display external links found in post descriptions. (Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active).
\n- 📖 Manga/Comic Mode (Creator URLs only): Designed for sequential content.\n
\n- Downloads posts from oldest to newest.
\n- The 'Page Range' input is disabled as all posts are fetched.
\n- A filename style toggle button (e.g., 'Name: Post Title') appears at the top-right of the log area when this mode is active for a creator feed. Click it to cycle between naming styles:\n
\nName: Post Title (Default): The first file in a post is named after the cleaned post title (e.g., 'My Chapter 1.jpg'). Subsequent files in the *same post* will attempt to keep their original filenames (e.g., 'page_02.png', 'bonus_art.jpg'). If the post has only one file, it's named after the post title. This is generally recommended for most manga/comics. \nName: Original File: All files attempt to keep their original filenames. \nName: Original File: All files attempt to keep their original filenames. When this style is active, an input field for an optional filename prefix (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_OriginalFile.jpg'. \nName: Title+G.Num (Post Title + Global Numbering): All files across all posts in the current download session are named sequentially using the cleaned post title as a prefix, followed by a global counter. E.g.: Post 'Chapter 1' (2 files) -> 'Chapter 1 001.jpg', 'Chapter 1 002.png'. Next post 'Chapter 2' (1 file) -> 'Chapter 2 003.jpg'. Multithreading for post processing is automatically disabled for this style. \nName: Date Based: Files are named sequentially (001.ext, 002.ext, ...) based on the publish order. When this style is active, an input field for an optional filename prefix (e.g., 'MySeries_') will appear next to this style button. Example: 'MySeries_001.jpg'. Multithreading for post processing is automatically disabled for this style. \n
\n \n- For best results with the 'Name: Post Title', 'Name: Title+G.Num', or 'Name: Date Based' styles, use the 'Filter by Character(s)' field with the manga/series title for folder organization.
\n
\n \n
\nMain Actions (Left Panel)
\n\n- ⬇️ Start Download / 🔗 Extract Links: This button's text and function changes based on the 'Filter Files' radio button selection. It starts the main operation.
\n- ⏸️ Pause Download / ▶️ Resume Download: Allows for temporarily halting the current download/extraction process and resuming it later. Some UI settings can be changed while paused.
\n- ❌ Cancel & Reset UI: Stops the current operation and performs a soft reset of the UI. Your URL and download directory inputs are kept, but other settings and logs are cleared.
\n
",
+ "help_guide_step6_title": "⑥ Known Series/Characters List",
+ "help_guide_step6_content": "\nManaging the Known Series/Characters List (Bottom-Left)
\nThis section helps manage the Known.txt file, which is used for smart folder organization when 'Separate folders by Name/Title' is on, especially as a fallback if a post doesn't match your active 'Filter by Character(s)' input.
\n\n- Open Known.txt: Opens the
Known.txt file (located in the app directory) in your default text editor for advanced editing (like creating complex grouped aliases). \n- Search characters...: Filters the list of known names displayed below.
\n- List Widget: Displays the master names from your
Known.txt. Select entries here to delete them. \n- Add new series/character name (Input Field): Enter a name or group to add.\n
\n- Simple Name: e.g.,
My Awesome Series. Adds as a single entry. \n- Group for separate Known.txt entries: e.g.,
(Vivi, Ulti, Uta). Adds \"Vivi\", \"Ulti\", and \"Uta\" as three separate, individual entries to Known.txt. \n- Group for Shared Folder & Single Known.txt Entry (Tilde
~): e.g., (Character A, Char A)~. Adds an entry to Known.txt named \"Character A Char A\". \"Character A\" and \"Char A\" become aliases for this single folder/entry. \n
\n \n- Button ➕ Add: Adds the name/group from the input field above to the list and to
Known.txt. \n- Button ⤵️ Add to Filter:\n
\n- Located next to the '➕ Add' button for the 'Known Series/Characters' list.
\n- Clicking this opens a popup window showing all names from your
Known.txt file, each with a checkbox. \n- The popup includes a search bar to quickly filter the list of names.
\n- You can select one or more names using the checkboxes.
\n- Click 'Add Selected' to insert the chosen names into the main window's 'Filter by Character(s)' input field.
\n- If a selected name in
Known.txt was originally a group (e.g., defined as (Boa, Hancock) in Known.txt), it will be added to the filter field as (Boa, Hancock)~. Simple names are added as-is. \n- 'Select All' and 'Deselect All' buttons are available in the popup for convenience.
\n- Click 'Cancel' to close the popup without any changes.
\n
\n \n- Button 🗑️ Delete Selected: Deletes the selected name(s) from the list and from
Known.txt. \n- Button ❓ (This one!): Displays this comprehensive help guide.
\n
",
+ "help_guide_step7_title": "⑦ Log Area & Controls",
+ "help_guide_step7_content": "\nLog Area & Controls (Right Panel)
\n\n- 📜 Progress Log / Extracted Links Log (Label): Title for the main log area; changes if '🔗 Only Links' mode is active.
\n- Search links... / Button 🔍 (Link Search):\n
- Visible only when '🔗 Only Links' mode is active. Allows for real-time filtering of the extracted links shown in the main log by text, URL, or platform.
\n \n- Name: [Style] Button (Manga Filename Style):\n
- Visible only when Manga/Comic Mode is active for a creator feed and not in 'Links Only' or 'Archives Only' mode.
\n- Cycles through filename styles:
Post Title, Original File, Date Based. (See Manga/Comic Mode section for details). \n- When 'Original File' or 'Date Based' style is active, an input field for an optional filename prefix will appear next to this button.
\n
\n \n- Multi-part: [ON/OFF] Button:\n
- Toggles multi-segment downloads for individual large files.\n
- ON: Can speed up large file downloads (e.g., videos) but may increase UI stutter or log spam with many small files. A warning appears on activation. If a multipart download fails, it retries as a single stream.
\n- OFF (Default): Files are downloaded in a single stream.
\n
\n - Disabled if '🔗 Only Links' or '📦 Only Archives' mode is active.
\n
\n \n- Button 👁️ / 🙈 (Log Display Toggle): Changes the main log view:\n
\n- 👁️ Progress Log (Default): Shows all download activity, errors, and summaries.
\n- 🙈 Missed Character Log: Displays a list of key terms from post titles/content that were skipped due to your 'Filter by Character(s)' settings. Useful for identifying content you might be unintentionally missing.
\n
\n \n- Button 🔄 Reset: Clears all input fields, logs, and resets temporary settings to their defaults. Can only be used when no download is active.
\n- Main Log Output (Text Area): Displays detailed progress messages, errors, and summaries. If '🔗 Only Links' mode is active, this area displays the extracted links.
\n- Missed Character Log Output (Text Area): (Visible via 👁️ / 🙈 toggle) Shows posts/files skipped due to character filters.
\n- External Log Output (Text Area): Appears below the main log if 'Show external links in log' is checked. Displays external links found in post descriptions.
\n- Export Links Button:\n
- Visible and enabled only when '🔗 Only Links' mode is active and links have been extracted.
\n- Allows saving all extracted links to a
.txt file. \n
\n \n- Progress Label: [Status]: Displays the overall progress of the download or link extraction process (e.g., posts processed).
\n- File Progress Label: Displays the progress of individual file downloads, including speed and size, or multipart download status.
\n
",
+ "help_guide_step8_title": "⑧ Favorite Mode & Future Features",
+ "help_guide_step8_content": "\nFavorite Mode (Downloading from your Kemono.su Favorites)
\nThis mode allows you to download content directly from artists you have favorited on Kemono.su.
\n\n- ⭐ How to Activate:\n
\n- Check the '⭐ Favorite Mode' checkbox, located next to the '🔗 Only Links' radio button.
\n
\n \n- UI Changes in Favorite Mode:\n
\n- The '🔗 Creator/Post Kemono URL' input area is replaced with a message indicating Favorite Mode is active.
\n- The standard 'Start Download', 'Pause', 'Cancel' buttons are replaced with:\n
\n- '🖼️ Favorite Artists' button
\n- '📄 Favorite Posts' button
\n
\n \n- The '🍪 Use cookie' option is automatically enabled and locked, as cookies are required to fetch your favorites.
\n
\n \n- Button 🖼️ Favorite Artists:\n
\n- Clicking this opens a dialog that lists all artists you have favorited on Kemono.su.
\n- You can select one or more artists from this list to download their content.
\n
\n \n- Button 📄 Favorite Posts (Future Feature):\n
\n- Downloading specific favorited posts (especially in a sequential, manga-like order if they are part of a series) is a feature currently in development.
\n- The best way to handle favorited posts, particularly for sequential reading like manga, is still being considered.
\n- If you have specific ideas or use-cases for how you'd like to download and organize favorited posts (e.g., \"manga-style\" from favorites), please consider opening an issue or joining the discussion on the project's GitHub page. Your input is valuable!
\n
\n \n- Favorite Download Scope (Button):\n
\n- This button (next to 'Favorite Posts') controls where the selected favorite artists' content is downloaded:\n
\n- Scope: Selected Location: All selected artists are downloaded into the main 'Download Location' you set in the UI. Filters apply globally to all content.
\n- Scope: Artist Folders: For each selected artist, a subfolder (named after the artist) is automatically created inside your main 'Download Location'. That artist's content goes into their specific folder. Filters apply within each artist's dedicated folder.
\n
\n \n
\n \n- Filters in Favorite Mode:\n
\n- The '🎯 Filter by Character(s)', '🚫 Skip with words', and 'Filter Files' options you have set in the UI will still apply to the content downloaded from your selected favorite artists.
\n
\n \n
",
+ "help_guide_step9_title": "⑨ Key Files & Tour",
+ "help_guide_step9_content": "\nKey Files Used by the Application
\n\nKnown.txt:\n\n- Located in the application directory (where the
.exe or main.py is). \n- Stores your list of known series, characters, or series titles for automatic folder organization when 'Separate folders by Name/Title' is enabled.
\n- Format:\n
\n- Each line is one entry.
\n- Simple Name: e.g.,
My Awesome Series. Matching content will go into a folder named \"My Awesome Series\". \n- Grouped Aliases: e.g.,
(Character A, Char A, Alt Name A). Content matching \"Character A\", \"Char A\", OR \"Alt Name A\" will ALL go into a single folder named \"Character A Char A Alt Name A\" (after cleanup). All terms in the parentheses become aliases for that folder. \n
\n \n- Usage: Acts as a fallback for folder naming if a post doesn't match your active 'Filter by Character(s)' input. You can manage simple entries via the UI or edit the file directly for complex aliases. The app reloads it on startup or next use.
\n
\n \ncookies.txt (Optional):\n\n- If you use the 'Use cookie' feature and do not provide a direct cookie string or browse for a specific file, the app will look for a file named
cookies.txt in its directory. \n- Format: Must be in the Netscape cookie file format.
\n- Usage: Allows the downloader to use your browser's login session to access content that may be behind a login on Kemono/Coomer.
\n
\n \n
\nFirst-Time User Tour
\n\n- On first launch (or if reset), a welcome tour dialog appears, walking you through the main features. You can skip it or choose to \"Never show this tour again.\"
\n
\nMany UI elements also have tooltips that appear when you hover your mouse over them, providing quick hints.
\n"
+}
+
translations ["ja"]={
-"settings_dialog_title":"設定",
-"language_label":"言語:",
-"lang_english":"英語",
-"lang_japanese":"日本語",
-"theme_toggle_light":"ライトモードに切り替え",
-"theme_toggle_dark":"ダークモードに切り替え",
-"theme_tooltip_light":"アプリケーションの外観を明るく変更します。",
-"theme_tooltip_dark":"アプリケーションの外観を暗く変更します。",
-"ok_button":"OK",
-"appearance_group_title":"外観",
-"language_group_title":"言語設定",
-"creator_post_url_label":"🔗 Kemonoクリエイター/投稿URL:",
-"download_location_label":"📁 ダウンロード場所:",
-"filter_by_character_label":"🎯 キャラクターでフィルタリング (コンマ区切り):",
-"skip_with_words_label":"🚫 スキップする単語 (コンマ区切り):",
-"remove_words_from_name_label":"✂️ 名前から単語を削除:",
-"filter_all_radio":"すべて",
-"filter_images_radio":"画像/GIF",
-"filter_videos_radio":"動画",
-"filter_archives_radio":"📦 アーカイブのみ",
-"filter_links_radio":"🔗 リンクのみ",
-"filter_audio_radio":"🎧 音声のみ",
-"favorite_mode_checkbox_label":"⭐ お気に入りモード",
-"browse_button_text":"参照...",
-"char_filter_scope_files_text":"フィルター: ファイル",
-"char_filter_scope_files_tooltip":"現在のスコープ: ファイル\n\nファイル名で個々のファイルをフィルターします。いずれかのファイルが一致すれば投稿は保持されます。\nその投稿から一致するファイルのみがダウンロードされます。\n例: フィルター「ティファ」。ファイル「ティファ_アートワーク.jpg」が一致し、ダウンロードされます。\nフォルダー命名: 一致するファイル名のキャラクターを使用します。\n\nクリックして次に循環: 両方",
-"char_filter_scope_title_text":"フィルター: タイトル",
-"char_filter_scope_title_tooltip":"現在のスコープ: タイトル\n\n投稿タイトルで投稿全体をフィルターします。一致する投稿のすべてのファイルがダウンロードされます。\n例: フィルター「エアリス」。タイトル「エアリスの庭」の投稿が一致し、すべてのファイルがダウンロードされます。\nフォルダー命名: 一致する投稿タイトルのキャラクターを使用します。\n\nクリックして次に循環: ファイル",
-"char_filter_scope_both_text":"フィルター: 両方",
-"char_filter_scope_both_tooltip":"現在のスコープ: 両方 (タイトル、次にファイル)\n\n1. 投稿タイトルを確認: 一致する場合、投稿のすべてのファイルがダウンロードされます。\n2. タイトルが一致しない場合、ファイル名を確認: いずれかのファイルが一致する場合、そのファイルのみがダウンロードされます。\n例: フィルター「クラウド」。\n - 投稿「クラウド・ストライフ」(タイトル一致) -> すべてのファイルがダウンロードされます。\n - 投稿「バイクチェイス」と「クラウド_フェンリル.jpg」(ファイル一致) -> 「クラウド_フェンリル.jpg」のみがダウンロードされます。\nフォルダー命名: タイトル一致を優先し、次にファイル一致を優先します。\n\nクリックして次に循環: コメント",
-"char_filter_scope_comments_text":"フィルター: コメント (ベータ)",
-"char_filter_scope_comments_tooltip":"現在のスコープ: コメント (ベータ - ファイル優先、次にコメントをフォールバック)\n\n1. ファイル名を確認: 投稿内のいずれかのファイルがフィルターに一致する場合、投稿全体がダウンロードされます。このフィルター用語についてはコメントはチェックされません。\n2. ファイルが一致しない場合、次に投稿コメントを確認: コメントが一致する場合、投稿全体がダウンロードされます。\n例: フィルター「バレット」。\n - 投稿A: ファイル「バレット_ガンアーム.jpg」、「other.png」。ファイル「バレット_ガンアーム.jpg」が一致。投稿Aのすべてのファイルがダウンロードされます。「バレット」についてはコメントはチェックされません。\n - 投稿B: ファイル「ダイン.jpg」、「ウェポン.gif」。コメント: 「...バレット・ウォーレスの絵...」。「バレット」にファイル一致なし。コメントが一致。投稿Bのすべてのファイルがダウンロードされます。\nフォルダー命名: ファイル一致のキャラクターを優先し、次にコメント一致のキャラクターを優先します。\n\nクリックして次に循環: タイトル",
-"char_filter_scope_unknown_text":"フィルター: 不明",
-"char_filter_scope_unknown_tooltip":"現在のスコープ: 不明\n\nキャラクターフィルタースコープが不明な状態です。循環またはリセットしてください。\n\nクリックして次に循環: タイトル",
-"skip_words_input_tooltip":(
-"特定のコンテンツのダウンロードをスキップするために、単語をカンマ区切りで入力します(例: WIP, sketch, preview)。\n\n"
-"この入力の隣にある「スコープ: [タイプ]」ボタンは、このフィルターの適用方法を循環します:\n"
-"- スコープ: ファイル: 名前にこれらの単語のいずれかを含む場合、個々のファイルをスキップします。\n"
-"- スコープ: 投稿: タイトルにこれらの単語のいずれかを含む場合、投稿全体をスキップします。\n"
-"- スコープ: 両方: 両方を適用します(まず投稿タイトル、次に投稿タイトルがOKな場合は個々のファイル)。"
-),
-"remove_words_input_tooltip":(
-"ダウンロードしたファイル名から削除する単語をカンマ区切りで入力します(大文字・小文字を区別しません)。\n"
-"一般的な接頭辞や接尾辞を整理するのに役立ちます。\n"
-"例: patreon, kemono, [HD], _final"
-),
-"skip_scope_files_text":"スコープ: ファイル",
-"skip_scope_files_tooltip":"現在のスキップスコープ: ファイル\n\n「スキップする単語」のいずれかを含む場合、個々のファイルをスキップします。\n例: スキップする単語「WIP、スケッチ」。\n- ファイル「art_WIP.jpg」-> スキップ。\n- ファイル「final_art.png」-> ダウンロード (他の条件が満たされた場合)。\n\n投稿は他のスキップされないファイルについて引き続き処理されます。\nクリックして次に循環: 両方",
-"skip_scope_posts_text":"スコープ: 投稿",
-"skip_scope_posts_tooltip":"現在のスキップスコープ: 投稿\n\n「スキップする単語」のいずれかを含む場合、投稿全体をスキップします。\nスキップされた投稿のすべてのファイルは無視されます。\n例: スキップする単語「プレビュー、お知らせ」。\n- 投稿「エキサイティングなお知らせ!」-> スキップ。\n- 投稿「完成したアートワーク」-> 処理 (他の条件が満たされた場合)。\n\nクリックして次に循環: ファイル",
-"skip_scope_both_text":"スコープ: 両方",
-"skip_scope_both_tooltip":"現在のスキップスコープ: 両方 (投稿、次にファイル)\n\n1. 投稿タイトルを確認: タイトルにスキップワードが含まれている場合、投稿全体がスキップされます。\n2. 投稿タイトルがOKの場合、次に個々のファイル名を確認: ファイル名にスキップワードが含まれている場合、そのファイルのみがスキップされます。\n例: スキップする単語「WIP、スケッチ」。\n- 投稿「スケッチとWIP」(タイトル一致) -> 投稿全体がスキップされます。\n- 投稿「アートアップデート」(タイトルOK) とファイル:\n - 「キャラクター_WIP.jpg」(ファイル一致) -> スキップ。\n - 「最終シーン.png」(ファイルOK) -> ダウンロード。\n\nクリックして次に循環: 投稿",
-"skip_scope_unknown_text":"スコープ: 不明",
-"skip_scope_unknown_tooltip":"現在のスキップスコープ: 不明\n\nスキップワードスコープが不明な状態です。循環またはリセットしてください。\n\nクリックして次に循環: 投稿",
-"language_change_title":"言語が変更されました",
-"language_change_message":"言語が変更されました。すべての変更を完全に有効にするには、再起動が必要です。",
-"language_change_informative":"今すぐアプリケーションを再起動しますか?",
-"restart_now_button":"今すぐ再起動",
-"skip_zip_checkbox_label":".zipをスキップ",
-"skip_rar_checkbox_label":".rarをスキップ",
-"download_thumbnails_checkbox_label":"サムネイルのみダウンロード",
-"scan_content_images_checkbox_label":"コンテンツ内の画像をスキャン",
-"compress_images_checkbox_label":"WebPに圧縮",
-"separate_folders_checkbox_label":"名前/タイトルでフォルダを分ける",
-"subfolder_per_post_checkbox_label":"投稿ごとにサブフォルダ",
-"use_cookie_checkbox_label":"Cookieを使用",
-"use_multithreading_checkbox_base_label":"マルチスレッドを使用",
-"show_external_links_checkbox_label":"ログに外部リンクを表示",
-"manga_comic_mode_checkbox_label":"マンガ/コミックモード",
-"threads_label":"スレッド数:",
-"start_download_button_text":"⬇️ ダウンロード開始",
-"start_download_button_tooltip":"現在の設定でダウンロードまたはリンク抽出プロセスを開始します。",
-"extract_links_button_text":"🔗 リンクを抽出",
-"pause_download_button_text":"⏸️ 一時停止",
-"pause_download_button_tooltip":"進行中のダウンロードプロセスを一時停止します。",
-"resume_download_button_text":"▶️ 再開",
-"resume_download_button_tooltip":"ダウンロードを再開します。",
-"cancel_button_text":"❌ 中止してUIリセット",
-"cancel_button_tooltip":"進行中のダウンロード/抽出プロセスを中止し、UIフィールドをリセットします(URLとディレクトリは保持)。",
-"error_button_text":"エラー",
-"error_button_tooltip":"エラーによりスキップされたファイルを表示し、オプションで再試行します。",
-"cancel_retry_button_text":"❌ 再試行を中止",
-"known_chars_label_text":"🎭 既知の番組/キャラクター (フォルダ名用):",
-"open_known_txt_button_text":"Known.txtを開く",
-"known_chars_list_tooltip":"このリストには、「フォルダを分ける」がオンで、特定の「キャラクターでフィルタリング」が提供されていないか、投稿に一致しない場合に、自動フォルダ作成に使用される名前が含まれています。\n頻繁にダウンロードするシリーズ、ゲーム、またはキャラクターの名前を追加してください。",
-"open_known_txt_button_tooltip":"デフォルトのテキストエディタで「Known.txt」ファイルを開きます。\nファイルはアプリケーションのディレクトリにあります。",
-"add_char_button_text":"➕ 追加",
-"add_char_button_tooltip":"入力フィールドの名前を「既知の番組/キャラクター」リストに追加します。",
-"add_to_filter_button_text":"⤵️ フィルターに追加",
-"add_to_filter_button_tooltip":"「既知の番組/キャラクター」リストから名前を選択して、上の「キャラクターでフィルタリング」フィールドに追加します。",
-"delete_char_button_text":"🗑️ 選択項目を削除",
-"delete_char_button_tooltip":"選択した名前を「既知の番組/キャラクター」リストから削除します。",
-"radio_all_tooltip":"投稿で見つかったすべてのファイルタイプをダウンロードします。",
-"radio_images_tooltip":"一般的な画像形式(JPG、PNG、GIF、WEBPなど)のみをダウンロードします。",
-"radio_videos_tooltip":"一般的な動画形式(MP4、MKV、WEBM、MOVなど)のみをダウンロードします。",
-"radio_only_archives_tooltip":".zipおよび.rarファイルのみを排他的にダウンロードします。他のファイル固有のオプションは無効になります。",
-"radio_only_audio_tooltip":"一般的な音声形式(MP3、WAV、FLACなど)のみをダウンロードします。",
-"radio_only_links_tooltip":"ファイルをダウンロードする代わりに、投稿の説明から外部リンクを抽出して表示します。\nダウンロード関連のオプションは無効になります。",
-"favorite_mode_checkbox_tooltip":"お気に入りモードを有効にして、保存したアーティスト/投稿を閲覧します。\nこれにより、URL入力がお気に入り選択ボタンに置き換えられます。",
-"skip_zip_checkbox_tooltip":"チェックすると、.zipアーカイブファイルはダウンロードされません。\n(「アーカイブのみ」が選択されている場合は無効)。",
-"skip_rar_checkbox_tooltip":"チェックすると、.rarアーカイブファイルはダウンロードされません。\n(「アーカイブのみ」が選択されている場合は無効)。",
-"download_thumbnails_checkbox_tooltip":"フルサイズのファイルの代わりにAPIから小さなプレビュー画像をダウンロードします(利用可能な場合)。\n「コンテンツ内の画像をスキャン」もチェックされている場合、このモードではコンテンツスキャンで見つかった画像のみがダウンロードされます(APIサムネイルは無視)。",
-"scan_content_images_checkbox_tooltip":"チェックすると、ダウンローダーは投稿のHTMLコンテンツをスキャンして画像URL(
タグまたは直接リンクから)を探します。\nこれには、
タグの相対パスを完全なURLに解決することも含まれます。\n
タグの相対パス(例: /data/image.jpg)は完全なURLに解決されます。\n画像が投稿の説明にあるがAPIのファイル/添付ファイルリストにない場合に便利です。",
-"compress_images_checkbox_tooltip":"1.5MBを超える画像をWebP形式に圧縮します(Pillowが必要)。",
-"use_subfolders_checkbox_tooltip":"「キャラクターでフィルタリング」入力または投稿タイトルに基づいてサブフォルダを作成します。\n特定のフィルターが投稿に一致しない場合、フォルダ名のフォールバックとして「既知の番組/キャラクター」リストを使用します。\n単一投稿の「キャラクターでフィルタリング」入力と「カスタムフォルダ名」を有効にします。",
-"use_subfolder_per_post_checkbox_tooltip":"投稿ごとにサブフォルダを作成します。「フォルダを分ける」もオンの場合、キャラクター/タイトルフォルダ内に作成されます。",
-"use_cookie_checkbox_tooltip":"チェックすると、リクエストにアプリケーションディレクトリの「cookies.txt」(Netscape形式)のCookieを使用しようとします。\nKemono/Coomerでログインが必要なコンテンツにアクセスするのに便利です。",
-"cookie_text_input_tooltip":"Cookie文字列を直接入力します。\n「Cookieを使用」がチェックされていて、「cookies.txt」が見つからないか、このフィールドが空でない場合に使用されます。\n形式はバックエンドがどのように解析するかに依存します(例: 「name1=value1; name2=value2」)。",
-"use_multithreading_checkbox_tooltip":"同時操作を有効にします。詳細については、「スレッド数」入力を参照してください。",
-"thread_count_input_tooltip":(
-"同時操作の数。\n- 単一投稿: 同時ファイルダウンロード数(1~10推奨)。\n"
-"- クリエイターフィードURL: 同時に処理する投稿数(1~200推奨)。\n"
-" 各投稿内のファイルはそのワーカーによって1つずつダウンロードされます。\n「マルチスレッドを使用」がオフの場合、1スレッドが使用されます。"),
-"external_links_checkbox_tooltip":"チェックすると、メインログの下にセカンダリログパネルが表示され、投稿の説明で見つかった外部リンクが表示されます。\n(「リンクのみ」または「アーカイブのみ」モードがアクティブな場合は無効)。",
-"manga_mode_checkbox_tooltip":"投稿を古いものから新しいものへダウンロードし、ファイル名を投稿タイトルに基づいて変更します(クリエイターフィードのみ)。",
-"progress_log_label_text":"📜 進捗ログ:",
-"multipart_on_button_text":"マルチパート: オン",
-"multipart_on_button_tooltip":"マルチパートダウンロード: オン\n\n大きなファイルを複数のセグメントで同時にダウンロードします。\n- 単一の大きなファイル(例: 動画)のダウンロードを高速化できます。\n- CPU/ネットワーク使用量が増加する可能性があります。\n- 多くの小さなファイルがあるフィードでは、速度の利点はなく、UI/ログが煩雑になることがあります。\n- マルチパートが失敗した場合、シングルストリームで再試行します。\n\nクリックしてオフにします。",
-"multipart_off_button_text":"マルチパート: オフ",
-"multipart_off_button_tooltip":"マルチパートダウンロード: オフ\n\nすべてのファイルが単一のストリームを使用してダウンロードされます。\n- 安定しており、ほとんどのシナリオ、特に多くの小さなファイルに適しています。\n- 大きなファイルは連続してダウンロードされます。\n\nクリックしてオンにします(アドバイザリを参照)。",
-"reset_button_text":"🔄 リセット",
-"reset_button_tooltip":"すべての入力とログをデフォルト状態にリセットします(アイドル時のみ)。",
-"progress_idle_text":"進捗: アイドル",
-"missed_character_log_label_text":"🚫 見逃したキャラクターログ:",
-"creator_popup_title":"クリエイター選択",
-"creator_popup_search_placeholder":"名前、サービスで検索、またはクリエイターURLを貼り付け...",
-"creator_popup_add_selected_button":"選択項目を追加",
-"creator_popup_scope_characters_button":"スコープ: キャラクター",
-"creator_popup_scope_creators_button":"スコープ: クリエイター",
-"creator_popup_title_fetching": "クリエイター投稿",
-"creator_popup_posts_area_title": "取得済み投稿",
-"creator_popup_posts_search_placeholder": "タイトルで取得済み投稿を検索...",
-"no_posts_fetched_yet_status": "まだ投稿が取得されていません。",
-"fetched_posts_count_label": "{count}件の投稿を取得しました。キューに追加するものを選択してください。",
-"no_posts_found_for_selection": "選択したクリエイターの投稿が見つかりませんでした。",
-"fetched_posts_count_label_filtered": "フィルターに一致する{count}件の投稿を表示中。",
-"no_posts_match_search_filter": "検索フィルターに一致する投稿がありません。",
-"fetch_error_for_creator_label": "{creator_name}の取得エラー",
-"post_fetch_cancelled_status_done": "投稿の取得がキャンセルされました。",
-"failed_to_fetch_or_no_posts_label": "投稿の取得に失敗したか、投稿が見つかりませんでした。",
-"select_posts_to_queue_message": "キューに追加する投稿を少なくとも1つ選択してください。",
-"favorite_artists_button_text":"🖼️ お気に入りアーティスト",
-"favorite_artists_button_tooltip":"Kemono.su/Coomer.suでお気に入りのアーティストを閲覧してダウンロードします。",
-"favorite_posts_button_text":"📄 お気に入り投稿",
-"favorite_posts_button_tooltip":"Kemono.su/Coomer.suでお気に入りの投稿を閲覧してダウンロードします。",
-"favorite_scope_selected_location_text":"スコープ: 選択場所",
-"favorite_scope_selected_location_tooltip":"現在のお気に入りダウンロードスコープ: 選択場所\n\n選択したすべてのお気に入りアーティスト/投稿は、UIで指定されたメインの「ダウンロード場所」にダウンロードされます。\nフィルター(キャラクター、スキップワード、ファイルタイプ)は、これらのアーティストのすべてのコンテンツにグローバルに適用されます。\n\nクリックして変更: アーティストフォルダ",
-"favorite_scope_artist_folders_text":"スコープ: アーティストフォルダ",
-"favorite_scope_artist_folders_tooltip":"現在のお気に入りダウンロードスコープ: アーティストフォルダ\n\n選択した各お気に入りアーティスト/投稿に対して、メインの「ダウンロード場所」内に新しいサブフォルダ(アーティスト名)が作成されます。\nそのアーティスト/投稿のコンテンツは、特定のサブフォルダにダウンロードされます。\nフィルター(キャラクター、スキップワード、ファイルタイプ)は、各アーティストのフォルダ内で適用されます。\n\nクリックして変更: 選択場所",
-"favorite_scope_unknown_text":"スコープ: 不明",
-"favorite_scope_unknown_tooltip":"お気に入りのダウンロードスコープが不明です。クリックして循環します。",
-"manga_style_post_title_text":"名前: 投稿タイトル",
-"manga_style_original_file_text":"名前: 元ファイル名",
-"manga_style_date_based_text":"名前: 日付順",
-"manga_style_title_global_num_text":"名前: タイトル+通し番号",
-"manga_style_unknown_text":"名前: 不明なスタイル",
-"manga_style_post_title_tooltip":"""ファイルは投稿のタイトルに基づいて名前が付けられます。
-- 投稿の最初のファイルは、投稿のクリーンなタイトルを取得します(例:「私の第1章.jpg」)。
-- 投稿に複数のファイルがある場合、後続のファイルも投稿タイトルを使用して名前が付けられますが、「_1」、「_2」などの数字の接尾辞が付きます(例:「私の第1章_1.png」、「私の第1章_2.gif」)。接尾辞のカウンターは2番目のファイルから1で始まります。
-- 投稿にファイルが1つしかない場合は、接尾辞なしで投稿のタイトルに基づいて名前が付けられます。
-
-例:投稿「第一章」(3ファイル:originala.jpg、originalb.png、originalc.gif)
-出力:「第一章.jpg」、「第一章_1.png」、「第一章_2.gif」。""",
-"manga_style_original_file_tooltip":"ファイルは元のファイル名を保持しようとします。\n\n- このスタイルボタンの隣に表示される入力フィールドにオプションのプレフィックスを入力できます。\n\n例(プレフィックス「私のシリーズ」):「私のシリーズ_元のファイル.jpg」。\n例(プレフィックスなし):「元のファイル.jpg」。",
-"manga_style_date_based_tooltip":"ファイルは投稿の公開順に基づいて順番に名前が付けられます(例:001.ext、002.ext)。\n\n- このスタイルボタンの隣に表示される入力フィールドにオプションのプレフィックスを入力できます。\n- このスタイルでは、正しい番号付けを保証するために、投稿処理のマルチスレッドは自動的に無効になります。\n\n例(プレフィックス「私の漫画」):「私の漫画_001.jpg」、「私の漫画_002.png」。\n例(プレフィックスなし):「001.jpg」、「002.png」。",
-"manga_style_title_global_num_tooltip":"ファイルは投稿のタイトルと、すべての投稿にわたるグローバルな連番で名前が付けられます。\n\n- 形式:「[クリーンな投稿タイトル]_[グローバルカウンター].[ext]」\n- カウンター(例:_001、_002)は、現在のセッションでダウンロードされたすべてのファイルに対してインクリメントされます。\n- このスタイルでは、正しい番号付けを保証するために、投稿処理のマルチスレッドは自動的に無効になります。\n\n例:投稿「第1章」(2ファイル)->「第1章_001.jpg」、「第1章_002.png」。\n次の投稿「第2章」(1ファイル)->「第2章_003.jpg」。",
-"manga_style_unknown_tooltip":"漫画のファイル名スタイルは現在不明です。これは予期しないことです。有効なスタイルに切り替えてください。",
-"manga_style_cycle_tooltip_suffix":"クリックして次のスタイルに切り替えます。",
-"fav_artists_dialog_title":"お気に入りアーティスト",
-"fav_artists_loading_status":"お気に入りアーティストを読み込み中...",
-"fav_artists_search_placeholder":"アーティストを検索...",
-"fav_artists_select_all_button":"すべて選択",
-"fav_artists_deselect_all_button":"すべて選択解除",
-"fav_artists_download_selected_button":"選択項目をダウンロード",
-"fav_artists_cancel_button":"キャンセル",
-"fav_artists_loading_from_source_status":"⏳ {source_name} からお気に入りを読み込み中...",
-"fav_artists_found_status":"{count} 人のお気に入りアーティストが見つかりました。",
-"fav_artists_none_found_status":"Kemono.suまたはCoomer.suにお気に入りアーティストが見つかりません。",
-"fav_artists_failed_status":"お気に入りの取得に失敗しました。",
-"fav_artists_cookies_required_status":"エラー: Cookieが有効ですが、どのソースからも読み込めませんでした。",
-"fav_artists_no_favorites_after_processing":"処理後にお気に入りアーティストが見つかりませんでした。",
-"fav_artists_no_selection_title":"選択なし",
-"fav_artists_no_selection_message":"ダウンロードするアーティストを少なくとも1人選択してください。",
-
-"fav_posts_dialog_title":"お気に入り投稿",
-"fav_posts_loading_status":"お気に入り投稿を読み込み中...",
-"fav_posts_search_placeholder":"投稿を検索 (タイトル、クリエイター、ID、サービス)...",
-"fav_posts_select_all_button":"すべて選択",
-"fav_posts_deselect_all_button":"すべて選択解除",
-"fav_posts_download_selected_button":"選択項目をダウンロード",
-"fav_posts_cancel_button":"キャンセル",
-"fav_posts_cookies_required_error":"エラー: お気に入り投稿にはCookieが必要ですが、読み込めませんでした。",
-"fav_posts_auth_failed_title":"認証失敗 (投稿)",
-"fav_posts_auth_failed_message":"認証エラーのため、お気に入り{domain_specific_part}を取得できませんでした:\n\n{error_message}\n\nこれは通常、サイトのCookieがないか、無効であるか、期限切れであることを意味します。Cookieの設定を確認してください。",
-"fav_posts_fetch_error_title":"取得エラー",
-"fav_posts_fetch_error_message":"{domain}からのお気に入り取得エラー{error_message_part}",
-"fav_posts_no_posts_found_status":"お気に入り投稿が見つかりません。",
-"fav_posts_found_status":"{count}件のお気に入り投稿が見つかりました。",
-"fav_posts_display_error_status":"投稿の表示エラー: {error}",
-"fav_posts_ui_error_title":"UIエラー",
-"fav_posts_ui_error_message":"お気に入り投稿を表示できませんでした: {error}",
-"fav_posts_auth_failed_message_generic":"認証エラーのため、お気に入り{domain_specific_part}を取得できませんでした。これは通常、サイトのCookieがないか、無効であるか、期限切れであることを意味します。Cookieの設定を確認してください。",
-"key_fetching_fav_post_list_init":"お気に入り投稿リストを取得中...", # JA_ADD_KEY_HERE
-"empty_popup_button_tooltip_text": "クリエイター選択を開く (creators.json を参照)",
-"key_fetching_from_source_kemono_su":"Kemono.suからお気に入りを取得中...",
-"key_fetching_from_source_coomer_su":"Coomer.suからお気に入りを取得中...",
-"fav_posts_fetch_cancelled_status":"お気に入り投稿の取得がキャンセルされました。",
-"items_in_queue_placeholder": "ポップアップからキューに{count}件のアイテムがあります。",
-"post_fetch_finished_status": "選択したクリエイターの投稿の取得が完了しました。",
-
-"known_names_filter_dialog_title":"既知の名前をフィルターに追加",
-"known_names_filter_search_placeholder":"名前を検索...",
-"known_names_filter_select_all_button":"すべて選択",
-"known_names_filter_deselect_all_button":"すべて選択解除",
-"known_names_filter_add_selected_button":"選択項目を追加",
-
-"error_files_dialog_title":"エラーによりスキップされたファイル",
-"error_files_no_errors_label":"前回のセッションまたは再試行後にエラーでスキップされたと記録されたファイルはありません。",
-"error_files_found_label":"以下の{count}個のファイルがダウンロードエラーによりスキップされました:",
-"error_files_select_all_button":"すべて選択",
-"error_files_retry_selected_button":"選択項目を再試行",
-"error_files_export_urls_button":"URLを.txtにエクスポート",
-"error_files_no_selection_retry_message":"再試行するファイルを少なくとも1つ選択してください。",
-"error_files_no_errors_export_title":"エラーなし",
-"error_files_no_errors_export_message":"エクスポートするエラーファイルのURLはありません。",
-"error_files_no_urls_found_export_title":"URLが見つかりません",
-"error_files_no_urls_found_export_message":"エラーファイルリストからエクスポートするURLを抽出できませんでした。",
-"error_files_save_dialog_title":"エラーファイルのURLを保存",
-"error_files_export_success_title":"エクスポート成功",
-"error_files_export_success_message":"{count}件のエントリを正常にエクスポートしました:\n{filepath}",
-"error_files_export_error_title":"エクスポートエラー",
-"error_files_export_error_message":"ファイルリンクをエクスポートできませんでした: {error}",
-"export_options_dialog_title":"エクスポートオプション",
-"export_options_description_label":"エラーファイルリンクのエクスポート形式を選択してください:",
-"export_options_radio_link_only":"1行に1リンク (URLのみ)",
-"export_options_radio_link_only_tooltip":"失敗した各ファイルの直接ダウンロードURLのみを1行に1URLずつエクスポートします。",
-"export_options_radio_with_details":"詳細付きでエクスポート (URL [投稿、ファイル情報])",
-"export_options_radio_with_details_tooltip":"URLの後に投稿タイトル、投稿ID、元のファイル名などの詳細を角括弧で囲んでエクスポートします。",
-"export_options_export_button":"エクスポート",
-
-"no_errors_logged_title":"エラー記録なし",
-"no_errors_logged_message":"前回のセッションまたは再試行後にエラーでスキップされたと記録されたファイルはありません。",
-
-"progress_initializing_text":"進捗: 初期化中...",
-"progress_posts_text":"進捗: {processed_posts} / {total_posts} 件の投稿 ({progress_percent:.1f}%)",
-"progress_processing_post_text":"進捗: 投稿 {processed_posts} を処理中...",
-"progress_starting_text":"進捗: 開始中...",
-"downloading_file_known_size_text":"'{filename}' をダウンロード中 ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)",
-"downloading_file_unknown_size_text":"'{filename}' をダウンロード中 ({downloaded_mb:.1f}MB)",
-"downloading_multipart_text":"DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts}パーツ @ {speed:.2f} MB/s)",
-"downloading_multipart_initializing_text":"ファイル: {filename} - パーツを初期化中...",
-"status_cancelled_by_user":"ユーザーによってキャンセルされました",
-"files_downloaded_label":"ダウンロード済み",
-"files_skipped_label":"スキップ済み",
-"retry_finished_text":"再試行完了",
-"succeeded_text":"成功",
-"status_completed":"完了",
-"failed_text":"失敗",
-"ready_for_new_task_text":"新しいタスクの準備ができました。",
-"fav_mode_active_label_text":"⭐ お気に入りモードが有効です。お気に入りのアーティスト/投稿を選択する前に、以下のフィルターを選択してください。下のアクションを選択してください。",
-"export_links_button_text":"リンクをエクスポート",
-"download_extracted_links_button_text":"ダウンロード",
-"log_display_mode_links_view_text":"🔗 リンク表示",
-"download_selected_links_dialog_button_text":"選択項目をダウンロード",
-"download_external_links_dialog_title":"選択した外部リンクのダウンロード",
-"download_external_links_dialog_main_label":"サポートされているリンクが{count}件見つかりました (Mega, GDrive, Dropbox)。ダウンロードするものを選択してください:",
-"select_all_button_text":"すべて選択",
-"deselect_all_button_text":"すべて選択解除",
-"download_selected_button_text":"選択項目をダウンロード",
-"link_input_placeholder_text":"例: https://kemono.su/patreon/user/12345 または .../post/98765",
-"link_input_tooltip_text":"Kemono/Coomerクリエイターのページまたは特定の投稿の完全なURLを入力します。\n例 (クリエイター): https://kemono.su/patreon/user/12345\n例 (投稿): https://kemono.su/patreon/user/12345/post/98765",
-"dir_input_placeholder_text":"ダウンロードを保存するフォルダを選択",
-"dir_input_tooltip_text":"ダウンロードされたすべてのコンテンツが保存されるメインフォルダを入力または参照します。\n「リンクのみ」モードが選択されていない限り必須です。",
-"character_input_placeholder_text":"例: ティファ, エアリス, (クラウド, ザックス)",
-"custom_folder_input_placeholder_text":"任意: この投稿を特定のフォルダに保存",
-"custom_folder_input_tooltip_text":"単一の投稿URLをダウンロードし、かつ「名前/タイトルでフォルダを分ける」が有効な場合、\nその投稿のダウンロードフォルダにカスタム名を入力できます。\n例: お気に入りのシーン",
-"skip_words_input_placeholder_text":"例: WM, WIP, スケッチ, プレビュー",
-"remove_from_filename_input_placeholder_text":"例: patreon, HD",
-"cookie_text_input_placeholder_no_file_selected_text":"Cookie文字列 (cookies.txt未選択時)",
-"cookie_text_input_placeholder_with_file_selected_text":"選択されたCookieファイルを使用中 (参照...を参照)",
-"character_search_input_placeholder_text":"キャラクターを検索...",
-"character_search_input_tooltip_text":"既知の番組/キャラクターのリストを以下でフィルタリングするには、ここに入力します。",
-"new_char_input_placeholder_text":"新しい番組/キャラクター名を追加",
-"new_char_input_tooltip_text":"上記のリストに新しい番組、ゲーム、またはキャラクター名を入力します。",
-"link_search_input_placeholder_text":"リンクを検索...",
-"link_search_input_tooltip_text":"「リンクのみ」モードの場合、表示されるリンクをテキスト、URL、またはプラットフォームでフィルタリングするには、ここに入力します。",
-"manga_date_prefix_input_placeholder_text":"マンガファイル名のプレフィックス",
-"manga_date_prefix_input_tooltip_text":"「日付順」または「元ファイル名」マンガファイル名のオプションのプレフィックス(例: 「シリーズ名」)。\n空の場合、ファイルはプレフィックスなしのスタイルに基づいて名前が付けられます。",
-"empty_popup_button_tooltip_text":"クリエイター選択を開く\n\n「creators.json」ファイルからクリエイターを閲覧・選択します。\n選択したクリエイター名がURL入力フィールドに追加されます。",
-"log_display_mode_progress_view_text":"⬇️ 進捗表示",
-"cookie_browse_button_tooltip":"Cookieファイル(Netscape形式、通常はcookies.txt)を参照します。\n「Cookieを使用」がチェックされていて、上のテキストフィールドが空の場合に使用されます。",
-"page_range_label_text":"ページ範囲:",
-"thread_count_input_tooltip":"同時操作の数。クリエイターフィードの投稿処理または単一投稿のファイルダウンロードに影響します。「マルチスレッドを使用」がオフの場合、1スレッドが使用されます。",
-"start_page_input_placeholder":"開始",
-"start_page_input_tooltip":"クリエイターURLの場合: ダウンロードを開始する開始ページ番号を指定します(例: 1, 2, 3)。\n最初のページから開始する場合は空白にするか、1に設定します。\n単一投稿URLまたはマンガ/コミックモードでは無効です。",
-"page_range_to_label_text":"から",
-"end_page_input_placeholder":"終了",
-"end_page_input_tooltip":"クリエイターURLの場合: ダウンロードする終了ページ番号を指定します(例: 5, 10)。\n開始ページからすべてのページをダウンロードする場合は空白にします。\n単一投稿URLまたはマンガ/コミックモードでは無効です。",
-"known_names_help_button_tooltip_text":"アプリケーション機能ガイドを開きます。",
-"future_settings_button_tooltip_text":"アプリケーション設定を開きます(テーマ、言語など)。",
-"link_search_button_tooltip_text":"表示されたリンクをフィルター",
-"confirm_add_all_dialog_title":"新しい名前の追加を確認",
-"confirm_add_all_info_label":"「キャラクターでフィルタリング」入力からの以下の新しい名前/グループは「Known.txt」にありません。\n追加すると、将来のダウンロードのフォルダ整理が改善されます。\n\nリストを確認してアクションを選択してください:",
-"confirm_add_all_select_all_button":"すべて選択",
-"confirm_add_all_deselect_all_button":"すべて選択解除",
-"confirm_add_all_add_selected_button":"選択項目をKnown.txtに追加",
-"confirm_add_all_skip_adding_button":"これらの追加をスキップ",
-"confirm_add_all_cancel_download_button":"ダウンロードをキャンセル",
-"cookie_help_dialog_title":"Cookieファイルの説明",
-"cookie_help_instruction_intro":"Cookieを使用するには、通常ブラウザからcookies.txtファイルが必要です。
",
-"cookie_help_how_to_get_title":"cookies.txtの入手方法:
",
-"download_history_dialog_title_first_processed": "最初に処理されたファイルの履歴",
-"first_files_processed_header": "このセッションで最初に処理された {count} 個のファイル:",
-"history_file_label": "ファイル:",
-"history_from_post_label": "投稿元:",
-"history_post_uploaded_label": "投稿アップロード日時:",
-"history_file_downloaded_label": "ファイルダウンロード日時:",
-"download_history_dialog_title_empty": "ダウンロード履歴 (空)",
-"no_download_history_header": "まだダウンロードがありません",
-"cookie_help_step1_extension_intro":"Chromeベースのブラウザに「Get cookies.txt LOCALLY」拡張機能をインストールします:
ChromeウェブストアでGet cookies.txt LOCALLYを入手",
-"cookie_help_step2_login":"ウェブサイト(例: kemono.suまたはcoomer.su)にアクセスし、必要に応じてログインします。",
-"cookie_help_step3_click_icon":"ブラウザのツールバーにある拡張機能のアイコンをクリックします。",
-"cookie_help_step4_export":"「エクスポート」ボタン(例: 「名前を付けてエクスポート」、「cookies.txtをエクスポート」 - 正確な文言は拡張機能のバージョンによって異なる場合があります)をクリックします。",
-"cookie_help_step5_save_file":"ダウンロードしたcookies.txtファイルをコンピュータに保存します。",
-"cookie_help_step6_app_intro":"このアプリケーションで:",
-"cookie_help_step6a_checkbox":"- 「Cookieを使用」チェックボックスがオンになっていることを確認します。
",
-"cookie_help_step6b_browse":"- Cookieテキストフィールドの隣にある「参照...」ボタンをクリックします。
",
-"cookie_help_step6c_select":"- 保存した
cookies.txtファイルを選択します。
",
-"cookie_help_alternative_paste":"または、一部の拡張機能ではCookie文字列を直接コピーできる場合があります。その場合は、ファイルを参照する代わりにテキストフィールドに貼り付けることができます。
",
-"cookie_help_proceed_without_button":"Cookieなしでダウンロード",
-"cookie_help_cancel_download_button":"ダウンロードをキャンセル",
-"character_input_tooltip":(
-"キャラクター名を入力してください(カンマ区切り)。「フォルダを分ける」が有効な場合、高度なグルーピングに対応し、フォルダ名に影響します。\n\n"
-"例:\n"
-"- Nami → 'Nami'に一致し、「Nami」フォルダが作成されます。\n"
-"- (Ulti, Vivi) → いずれかに一致し、「Ulti Vivi」フォルダが作成され、両方の名前がKnown.txtに個別に追加されます。\n"
-"- (Boa, Hancock)~ → いずれかに一致し、「Boa Hancock」フォルダが作成され、Known.txtに1つのグループとして追加されます。\n\n"
-"入力された名前は、コンテンツ照合時のエイリアスとして機能します。\n\n"
-"フィルターモード(ボタンで切り替え):\n"
-"- ファイル: ファイル名でフィルターします。\n"
-"- タイトル: 投稿タイトルでフィルターします。\n"
-"- 両方: まず投稿タイトルを確認し、一致しない場合はファイル名を確認します。\n"
-"- コメント(ベータ版): まずファイル名を確認し、一致しない場合は投稿コメントを確認します。"
-),
-"tour_dialog_title":"Kemonoダウンローダーへようこそ!",
-"tour_dialog_never_show_checkbox":"今後このツアーを表示しない",
-"tour_dialog_skip_button":"ツアーをスキップ",
-"tour_dialog_back_button":"戻る",
-"tour_dialog_next_button":"次へ",
-"tour_dialog_finish_button":"完了",
-"tour_dialog_step1_title":"👋 ようこそ!",
-"tour_dialog_step1_content":"""このクイックツアーでは、Kemonoダウンローダーの主な機能(強化されたフィルタリング、マンガモードの改善、Cookie管理など、最近の更新を含む)を説明します。
-
- - 私の目標は、KemonoとCoomerからコンテンツを簡単にダウンロードできるようにすることです。
- - 🎨 クリエイター選択ボタン: URL入力の隣にあるパレットアイコンをクリックするとダイアログが開きます。
creators.jsonファイルからクリエイターを閲覧・選択して、URL入力に名前をすばやく追加できます。
- - 重要ヒント: アプリが「(応答なし)」になる場合
- 「ダウンロード開始」をクリックした後、特に大規模なクリエイターフィードや多数のスレッドを使用する場合、アプリケーションが一時的に「(応答なし)」と表示されることがあります。お使いのオペレーティングシステム(Windows、macOS、Linux)が「プロセスの終了」や「強制終了」を提案することさえあるかもしれません。
- しばらくお待ちください! アプリは多くの場合、バックグラウンドで懸命に動作しています。強制終了する前に、選択した「ダウンロード場所」をファイルエクスプローラーで確認してみてください。新しいフォルダが作成されたり、ファイルが表示されたりしている場合は、ダウンロードが正しく進行していることを意味します。応答性が回復するまでしばらく時間をおいてください。
- - 次へと戻るボタンで移動します。
- - 多くのオプションには、マウスオーバーすると詳細が表示されるツールチップがあります。
- - いつでもこのガイドを閉じるにはツアーをスキップをクリックします。
- - 今後の起動時にこれを見たくない場合は「今後このツアーを表示しない」をチェックします。
-
""",
-"tour_dialog_step2_title":"①はじめに",
-"tour_dialog_step2_content":"""ダウンロードの基本から始めましょう:
-
- - 🔗 Kemonoクリエイター/投稿URL:
- クリエイターのページ(例: https://kemono.su/patreon/user/12345)
- または特定の投稿(例: .../post/98765)の完全なウェブアドレス(URL)を貼り付けます。
- またはCoomerクリエイター(例: https://coomer.su/onlyfans/user/artistname)
- - 📁 ダウンロード場所:
- 「参照...」をクリックして、ダウンロードしたすべてのファイルが保存されるコンピュータ上のフォルダを選択します。
- 「リンクのみ」モードを使用している場合を除き、これは必須です。
- - 📄 ページ範囲(クリエイターURLのみ):
- クリエイターのページからダウンロードする場合、取得するページの範囲を指定できます(例: 2ページから5ページ)。
- すべてのページを取得するには空白のままにします。これは単一の投稿URLまたはマンガ/コミックモードがアクティブな場合は無効になります。
-
""",
-"tour_dialog_step3_title":"② ダウンロードのフィルタリング",
-"tour_dialog_step3_content":"""これらのフィルターでダウンロードするものを絞り込みます(ほとんどは「リンクのみ」または「アーカイブのみ」モードでは無効になります):
-
- - 🎯 キャラクターでフィルタリング:
- キャラクター名をコンマ区切りで入力します(例: ティファ, エアリス)。結合されたフォルダ名のエイリアスをグループ化します: (エイリアス1, エイリアス2, エイリアス3) は「エイリアス1 エイリアス2 エイリアス3」(クリーニング後)というフォルダになります。グループ内のすべての名前が照合用のエイリアスとして使用されます。
- この入力の隣にある「フィルター: [タイプ]」ボタンは、このフィルターの適用方法を循環します:
- - フィルター: ファイル: 個々のファイル名を確認します。いずれかのファイルが一致すれば投稿は保持され、一致するファイルのみがダウンロードされます。「フォルダを分ける」がオンの場合、フォルダ名は一致するファイル名のキャラクターを使用します。
- - フィルター: タイトル: 投稿タイトルを確認します。一致する投稿のすべてのファイルがダウンロードされます。フォルダ名は一致する投稿タイトルのキャラクターを使用します。
- - ⤵️ フィルターに追加ボタン(既知の名前): 既知の名前の「追加」ボタン(ステップ5参照)の隣にあり、これをクリックするとポップアップが開きます。
Known.txtリストからチェックボックス(検索バー付き)で名前を選択し、「キャラクターでフィルタリング」フィールドにすばやく追加します。Known.txtの(ボア, ハンコック)のようなグループ化された名前は、フィルターフィールドに(ボア, ハンコック)~として追加されます。
- - フィルター: 両方: まず投稿タイトルを確認します。一致する場合、すべてのファイルがダウンロードされます。一致しない場合、次にファイル名を確認し、一致するファイルのみがダウンロードされます。フォルダ名はタイトル一致を優先し、次にファイル一致を優先します。
- - フィルター: コメント(ベータ): まずファイル名を確認します。ファイルが一致する場合、投稿のすべてのファイルがダウンロードされます。ファイル一致がない場合、次に投稿コメントを確認します。コメントが一致する場合、投稿のすべてのファイルがダウンロードされます。(より多くのAPIリクエストを使用します)。フォルダ名はファイル一致を優先し、次にコメント一致を優先します。
- 「名前/タイトルでフォルダを分ける」が有効な場合、このフィルターはフォルダ名にも影響します。
- - 🚫 スキップする単語:
- 単語をコンマ区切りで入力します(例: WIP, スケッチ, プレビュー)。
- この入力の隣にある「スコープ: [タイプ]」ボタンは、このフィルターの適用方法を循環します:
- - スコープ: ファイル: 名前にこれらの単語のいずれかを含む場合、ファイルをスキップします。
- - スコープ: 投稿: タイトルにこれらの単語のいずれかを含む場合、投稿全体をスキップします。
- - スコープ: 両方: ファイルと投稿タイトルの両方のスキップを適用します(まず投稿、次にファイル)。
- - ファイルフィルター(ラジオボタン): ダウンロードするものを選択します:
-
- - すべて: 見つかったすべてのファイルタイプをダウンロードします。
- - 画像/GIF: 一般的な画像形式とGIFのみ。
- - 動画: 一般的な動画形式のみ。
- - 📦 アーカイブのみ: .zipと.rarファイルのみをダウンロードします。選択すると、「.zipをスキップ」と「.rarをスキップ」チェックボックスは自動的に無効になり、チェックが外れます。「外部リンクをログに表示」も無効になります。
- - 🎧 音声のみ: 一般的な音声形式のみ(MP3、WAV、FLACなど)。
- - 🔗 リンクのみ: ファイルをダウンロードする代わりに、投稿の説明から外部リンクを抽出して表示します。ダウンロード関連のオプションと「外部リンクをログに表示」は無効になります。
-
-
""",
-"tour_dialog_step4_title":"③ お気に入りモード(代替ダウンロード)",
-"tour_dialog_step4_content":"""アプリケーションは、Kemono.suでお気に入りに登録したアーティストからコンテンツをダウンロードするための「お気に入りモード」を提供しています。
-
- - ⭐ お気に入りモードチェックボックス:
- 「🔗 リンクのみ」ラジオボタンの隣にあります。これをチェックするとお気に入りモードが有効になります。
- - お気に入りモードでの動作:
-
- 「🔗 Kemonoクリエイター/投稿URL」入力エリアは、お気に入りモードがアクティブであることを示すメッセージに置き換えられます。
- - 標準の「ダウンロード開始」、「一時停止」、「キャンセル」ボタンは、「🖼️ お気に入りアーティスト」と「📄 お気に入り投稿」ボタンに置き換えられます(注意: 「お気に入り投稿」は将来の機能です)。
- - お気に入りを取得するにはCookieが必要なため、「🍪 Cookieを使用」オプションは自動的に有効になり、ロックされます。
- - 🖼️ お気に入りアーティストボタン:
- これをクリックすると、Kemono.suでお気に入りに登録したアーティストのリストが表示されるダイアログが開きます。このリストから1人以上のアーティストを選択してダウンロードできます。
- - お気に入りダウンロードスコープ(ボタン):
- このボタン(「お気に入り投稿」の隣)は、選択したお気に入りのダウンロード場所を制御します:
- - スコープ: 選択場所: 選択したすべてのアーティストは、UIで設定したメインの「ダウンロード場所」にダウンロードされます。フィルターはグローバルに適用されます。
- - スコープ: アーティストフォルダ: 選択した各アーティストについて、メインの「ダウンロード場所」内にサブフォルダ(アーティスト名)が作成されます。そのアーティストのコンテンツは、特定のサブフォルダにダウンロードされます。フィルターは各アーティストのフォルダ内で適用されます。
- - お気に入りモードでのフィルター:
- 「キャラクターでフィルタリング」、「スキップする単語」、「ファイルフィルター」オプションは、選択したお気に入りアーティストからダウンロードされるコンテンツにも適用されます。
-
""",
-"tour_dialog_step5_title":"④ ダウンロードの微調整",
-"tour_dialog_step5_content":"""ダウンロードをカスタマイズするためのその他のオプション:
-
- - .zipをスキップ / .rarをスキップ: これらのアーカイブファイルタイプをダウンロードしないようにするには、これらをチェックします。
- (注意: 「📦 アーカイブのみ」フィルターモードが選択されている場合、これらは無効になり、無視されます)。
- - ✂️ 名前から単語を削除:
- ダウンロードしたファイル名から削除する単語をコンマ区切りで入力します(大文字と小文字を区別しません)(例: patreon, [HD])。
- - サムネイルのみダウンロード: フルサイズのファイルの代わりに小さなプレビュー画像をダウンロードします(利用可能な場合)。
- - 大きな画像を圧縮: 「Pillow」ライブラリがインストールされている場合、1.5MBより大きい画像は、WebPバージョンが大幅に小さい場合にWebP形式に変換されます。
- - 🗄️ カスタムフォルダ名(単一投稿のみ):
- 単一の特定の投稿URLをダウンロードしていて、かつ「名前/タイトルでフォルダを分ける」が有効な場合、
- その投稿のダウンロードフォルダにカスタム名を入力できます。
- - 🍪 Cookieを使用: リクエストにCookieを使用するには、これをチェックします。次のいずれかを実行できます:
-
- Cookie文字列をテキストフィールドに直接入力します(例: name1=value1; name2=value2)。
- - 「参照...」をクリックしてcookies.txtファイル(Netscape形式)を選択します。パスがテキストフィールドに表示されます。
- これは、ログインが必要なコンテンツにアクセスする場合に便利です。テキストフィールド(入力されている場合)が優先されます。
- 「Cookieを使用」がチェックされていて、テキストフィールドと参照されたファイルの両方が空の場合、アプリのディレクトリから「cookies.txt」を読み込もうとします。
-
""",
-"tour_dialog_step6_title":"⑤ 整理とパフォーマンス",
-"tour_dialog_step6_content":"""ダウンロードを整理し、パフォーマンスを管理します:
-
- - ⚙️ 名前/タイトルでフォルダを分ける: 「キャラクターでフィルタリング」入力または投稿タイトルに基づいてサブフォルダを作成します(特定のフィルターが投稿に一致しない場合、フォルダ名のフォールバックとしてKnown.txtリストを使用できます)。
- - 投稿ごとにサブフォルダ: 「フォルダを分ける」がオンの場合、メインのキャラクター/タイトルフォルダ内に個々の投稿ごとに追加のサブフォルダを作成します。
- - 🚀 マルチスレッドを使用(スレッド数): より高速な操作を可能にします。「スレッド数」入力の数値の意味:
-
- クリエイターフィードの場合: 同時に処理する投稿の数。各投稿内のファイルは、そのワーカーによって順番にダウンロードされます(「日付順」マンガ命名がオンの場合を除く。これは1つの投稿ワーカーを強制します)。
- - 単一投稿URLの場合: その単一投稿から同時にダウンロードするファイルの数。
- チェックされていない場合、1スレッドが使用されます。高いスレッド数(例: >40)はアドバイザリを表示する場合があります。
- - マルチパートダウンロード切り替え(ログエリアの右上):
- 「マルチパート: [オン/オフ]」ボタンは、個々の大きなファイルのマルチセグメントダウンロードを有効/無効にできます。
- - オン: 大きなファイルのダウンロード(例: 動画)を高速化できますが、多くの小さなファイルがある場合、UIの途切れやログのスパムが増加する可能性があります。有効にするとアドバイザリが表示されます。マルチパートダウンロードが失敗した場合、シングルストリームで再試行します。
- - オフ(デフォルト): ファイルは単一のストリームでダウンロードされます。
- 「リンクのみ」または「アーカイブのみ」モードがアクティブな場合は無効になります。
- - 📖 マンガ/コミックモード(クリエイターURLのみ): シーケンシャルコンテンツ向けに調整されています。
-
- - 投稿を古いものから新しいものへダウンロードします。
- - すべての投稿が取得されるため、「ページ範囲」入力は無効になります。
- - このモードがクリエイターフィードでアクティブな場合、ログエリアの右上にファイル名スタイル切り替えボタン(例: 「名前: 投稿タイトル」)が表示されます。クリックすると命名スタイルが循環します:
-
- - 名前: 投稿タイトル(デフォルト): 投稿の最初のファイルは、投稿のクリーンなタイトルにちなんで名付けられます(例: 「My Chapter 1.jpg」)。*同じ投稿*内の後続のファイルは、元のファイル名を保持しようとします(例: 「page_02.png」、「bonus_art.jpg」)。投稿にファイルが1つしかない場合は、投稿タイトルにちなんで名付けられます。これはほとんどのマンガ/コミックに一般的に推奨されます。
- - 名前: 元ファイル名: すべてのファイルが元のファイル名を保持しようとします。オプションのプレフィックス(例: 「MySeries_」)を、このスタイルボタンの隣に表示される入力フィールドに入力できます。例: 「MySeries_OriginalFile.jpg」。
- - 名前: タイトル+通し番号(投稿タイトル+グローバル番号付け): 現在のダウンロードセッションのすべての投稿のすべてのファイルが、投稿のクリーンなタイトルをプレフィックスとして使用し、グローバルカウンターを続けて順番に名付けられます。例: 投稿「Chapter 1」(2ファイル)-> 「Chapter 1_001.jpg」、「Chapter 1_002.png」。次の投稿「Chapter 2」(1ファイル)は番号付けを続けます -> 「Chapter 2_003.jpg」。このスタイルの場合、正しいグローバル番号付けを保証するために、投稿処理のマルチスレッドは自動的に無効になります。
- - 名前: 日付順: ファイルは投稿の公開順に基づいて順番に名付けられます(001.ext、002.extなど)。オプションのプレフィックス(例: 「MySeries_」)を、このスタイルボタンの隣に表示される入力フィールドに入力できます。例: 「MySeries_001.jpg」。このスタイルの場合、投稿処理のマルチスレッドは自動的に無効になります。
-
-
- - 「名前: 投稿タイトル」、「名前: タイトル+通し番号」、または「名前: 日付順」スタイルで最良の結果を得るには、「キャラクターでフィルタリング」フィールドにマンガ/シリーズのタイトルを入力してフォルダを整理します。
-
- - 🎭 Known.txtによるスマートなフォルダ整理:
- Known.txt(アプリのディレクトリ内)は、「名前/タイトルでフォルダを分ける」がアクティブな場合の自動フォルダ整理を細かく制御できます。
- # JA_PLACEHOLDER
- - 仕組み:
Known.txtの各行がエントリです。
- My Awesome Seriesのような単純な行は、これに一致するコンテンツが「My Awesome Series」という名前のフォルダに入ることを意味します。
- (Character A, Char A, Alt Name A)のようなグループ化された行は、「Character A」、「Char A」、または「Alt Name A」に一致するコンテンツがすべて「Character A Char A Alt Name A」(クリーニング後)という名前の単一フォルダに入ることを意味します。括弧内のすべての用語がそのフォルダのエイリアスになります。
- - インテリジェントなフォールバック: 「名前/タイトルでフォルダを分ける」がアクティブで、投稿が特定の「キャラクターでフィルタリング」入力に一致しない場合、ダウンローダーは
Known.txtを参照して、フォルダ作成用の一致するプライマリ名を見つけます。
- - ユーザーフレンドリーな管理: UIリスト(下記)から単純な(グループ化されていない)名前を追加します。高度な編集(グループ化されたエイリアスの作成/変更など)の場合は、「Known.txtを開く」をクリックしてテキストエディタでファイルを編集します。アプリは次回使用時または起動時に再読み込みします。
-
-
-
""",
-"tour_dialog_step7_title":"⑥ 一般的なエラーとトラブルシューティング",
-"tour_dialog_step7_content":"""ダウンロード中に問題が発生することがあります。一般的なものをいくつか紹介します:
-
- - キャラクター入力ツールチップ:
- キャラクター名をコンマ区切りで入力します (例: ティファ, エアリス)。
- 結合されたフォルダ名のエイリアスをグループ化します: (エイリアス1, エイリアス2, エイリアス3) はフォルダ「エイリアス1 エイリアス2 エイリアス3」になります。
- グループ内のすべての名前が照合用のエイリアスとして使用されます。
- この入力の隣にある「フィルター: [タイプ]」ボタンは、このフィルターの適用方法を循環します:
- - フィルター: ファイル: 個々のファイル名を確認します。一致するファイルのみがダウンロードされます。
- - フィルター: タイトル: 投稿タイトルを確認します。一致する投稿のすべてのファイルがダウンロードされます。
- - フィルター: 両方: まず投稿タイトルを確認します。一致しない場合、次にファイル名を確認します。
- - フィルター: コメント (ベータ): まずファイル名を確認します。一致しない場合、次に投稿コメントを確認します。
- 「名前/タイトルでフォルダを分ける」が有効な場合、このフィルターはフォルダ名にも影響します。
- - 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout:
- これらは通常、Kemono/Coomerのサーバー側の一時的な問題を示します。サイトが過負荷になっているか、メンテナンス中であるか、問題が発生している可能性があります。
- 解決策: しばらく(例: 30分から数時間)待ってから、後でもう一度試してください。ブラウザで直接サイトを確認してください。
- - 接続喪失 / 接続拒否 / タイムアウト(ファイルダウンロード中):
- これは、インターネット接続、サーバーの不安定性、またはサーバーが大きなファイルの接続を切断した場合に発生する可能性があります。
- 解決策: インターネットを確認してください。「スレッド数」が高い場合は減らしてみてください。セッションの最後に一部の失敗したファイルを再試行するようアプリが促す場合があります。
- - IncompleteReadエラー:
- サーバーが予期したよりも少ないデータを送信しました。多くの場合、一時的なネットワークの不具合またはサーバーの問題です。
- 解決策: アプリは多くの場合、ダウンロードセッションの最後にこれらのファイルを再試行対象としてマークします。
- - 403 Forbidden / 401 Unauthorized(公開投稿ではあまり一般的ではありません):
- コンテンツにアクセスする権限がない可能性があります。一部の有料またはプライベートコンテンツの場合、「Cookieを使用」オプションをブラウザセッションの有効なCookieと共に使用すると役立つ場合があります。Cookieが最新であることを確認してください。
- - 404 Not Found:
- 投稿またはファイルのURLが正しくないか、コンテンツがサイトから削除されています。URLを再確認してください。
- - 「投稿が見つかりません」/「対象の投稿が見つかりません」:
- URLが正しく、クリエイター/投稿が存在することを確認してください。ページ範囲を使用している場合は、クリエイターに対して有効であることを確認してください。非常に新しい投稿の場合、APIに表示されるまでにわずかな遅延がある場合があります。
- - 全体的な遅さ / アプリ「(応答なし)」:
- ステップ1で述べたように、特に大規模なクリエイターフィードや多くのスレッドで開始後にアプリがハングするように見える場合は、しばらくお待ちください。バックグラウンドでデータを処理している可能性が高いです。これが頻繁に発生する場合は、スレッド数を減らすと応答性が向上することがあります。
-
""",
-"tour_dialog_step8_title":"⑦ ログと最終コントロール",
-"tour_dialog_step8_content":"""監視とコントロール:
-
- - 📜 進捗ログ / 抽出リンクログ: 詳細なダウンロードメッセージを表示します。「🔗 リンクのみ」モードがアクティブな場合、このエリアには抽出されたリンクが表示されます。
- - ログに外部リンクを表示: チェックすると、メインログの下にセカンダリログパネルが表示され、投稿の説明で見つかった外部リンクが表示されます。(「🔗 リンクのみ」または「📦 アーカイブのみ」モードがアクティブな場合は無効になります)。
- - ログビュー切り替え(👁️ / 🙈 ボタン):
- このボタン(ログエリアの右上)は、メインログビューを切り替えます:
- - 👁️ 進捗ログ(デフォルト): すべてのダウンロードアクティビティ、エラー、概要を表示します。
- - 🙈 見逃したキャラクターログ: 「キャラクターでフィルタリング」設定のためにスキップされた投稿タイトルのキーワードのリストを表示します。意図せずに見逃している可能性のあるコンテンツを特定するのに役立ちます。
- - 🔄 リセット: すべての入力フィールド、ログをクリアし、一時的な設定をデフォルトにリセットします。ダウンロードがアクティブでない場合にのみ使用できます。
- - ⬇️ ダウンロード開始 / 🔗 リンクを抽出 / ⏸️ 一時停止 / ❌ 中止: これらのボタンでプロセスを制御します。「中止してUIリセット」は現在の操作を停止し、URLとディレクトリ入力を保持してソフトUIリセットを実行します。「一時停止/再開」は一時的な停止と継続を可能にします。
- - 一部のファイルが回復可能なエラー(「IncompleteRead」など)で失敗した場合、セッションの最後に再試行するよう促される場合があります。
-
-
準備完了です!「完了」をクリックしてツアーを閉じ、ダウンローダーの使用を開始します。"""
+ "settings_dialog_title": "設定",
+ "language_label": "言語:",
+ "lang_english": "英語 (English)",
+ "lang_japanese": "日本語",
+ "theme_toggle_light": "ライトモードに切り替え",
+ "theme_toggle_dark": "ダークモードに切り替え",
+ "theme_tooltip_light": "アプリケーションの外観をライトに変更します。",
+ "theme_tooltip_dark": "アプリケーションの外観をダークに変更します。",
+ "ok_button": "OK",
+ "appearance_group_title": "外観",
+ "language_group_title": "言語設定",
+ "creator_post_url_label": "🔗クリエイター/投稿Kemono URL:",
+ "download_location_label": "📁ダウンロード場所:",
+ "filter_by_character_label": "🎯キャラクターでフィルター(コンマ区切り):",
+ "skip_with_words_label": "🚫単語でスキップ(コンマ区切り):",
+ "remove_words_from_name_label": "✂️名前から単語を削除:",
+ "filter_all_radio": "すべて",
+ "filter_images_radio": "画像/GIF",
+ "filter_videos_radio": "動画",
+ "filter_archives_radio": "📦アーカイブのみ",
+ "filter_links_radio": "🔗リンクのみ",
+ "filter_audio_radio": "🎧音声のみ",
+ "favorite_mode_checkbox_label": "⭐お気に入りモード",
+ "browse_button_text": "参照...",
+ "char_filter_scope_files_text": "フィルター: ファイル",
+ "char_filter_scope_files_tooltip": "現在のスコープ: ファイル\n\n個々のファイルを名前でフィルタリングします。いずれかのファイルが一致した場合、投稿は保持されます。\nその投稿から一致したファイルのみがダウンロードされます。\n例: フィルター 'Tifa'。ファイル 'Tifa_artwork.jpg' が一致し、ダウンロードされます。\nフォルダーの命名: 一致したファイル名のキャラクターを使用します。\n\nクリックして切り替え: 両方",
+ "char_filter_scope_title_text": "フィルター: タイトル",
+ "char_filter_scope_title_tooltip": "現在のスコープ: タイトル\n\n投稿全体をタイトルでフィルタリングします。一致した投稿のすべてのファイルがダウンロードされます。\n例: フィルター 'Aerith'。タイトルが 'Aerith's Garden' の投稿が一致し、すべてのファイルがダウンロードされます。\nフォルダーの命名: 一致した投稿タイトルのキャラクターを使用します。\n\nクリックして切り替え: ファイル",
+ "char_filter_scope_both_text": "フィルター: 両方",
+ "char_filter_scope_both_tooltip": "現在のスコープ: 両方(タイトル、次にファイル)\n\n1. 投稿タイトルを確認します。一致した場合、投稿内のすべてのファイルがダウンロードされます。\n2. タイトルが一致しない場合、ファイル名を確認します。ファイルが一致した場合、そのファイルのみがダウンロードされます。\n例: フィルター 'Cloud'。\n - 投稿 'Cloud Strife' (タイトル一致) -> すべてのファイルがダウンロードされます。\n - 投稿 'Motorcycle Chase' と 'Cloud_fenrir.jpg' (ファイル一致) -> 'Cloud_fenrir.jpg' のみがダウンロードされます。\nフォルダーの命名: タイトルの一致を優先し、次にファイルの一致を優先します。\n\nクリックして切り替え: コメント",
+ "char_filter_scope_comments_text": "フィルター: コメント(ベータ)",
+ "char_filter_scope_comments_tooltip": "現在のスコープ: コメント(ベータ - 最初にファイル、次にフォールバックとしてコメント)\n\n1. ファイル名を確認します。投稿内のファイルがフィルターに一致した場合、投稿全体がダウンロードされます。このフィルター用語ではコメントはチェックされません。\n2. ファイルが一致しない場合、次に投稿のコメントを確認します。コメントが一致した場合、投稿全体がダウンロードされます。\n例: フィルター 'Barret'。\n - 投稿A: ファイル 'Barret_gunarm.jpg', 'other.png'。ファイル 'Barret_gunarm.jpg' が一致します。投稿Aのすべてのファイルがダウンロードされます。'Barret' のコメントはチェックされません。\n - 投稿B: ファイル 'dyne.jpg', 'weapon.gif'。コメント: '...a drawing of Barret Wallace...'。'Barret' のファイル一致はありません。コメントが一致します。投稿Bのすべてのファイルがダウンロードされます。\nフォルダーの命名: ファイルの一致からのキャラクターを優先し、次にコメントの一致を優先します。\n\nクリックして切り替え: タイトル",
+ "char_filter_scope_unknown_text": "フィルター: 不明",
+ "char_filter_scope_unknown_tooltip": "現在のスコープ: 不明\n\nキャラクターフィルタースコープが不明な状態です。サイクルまたはリセットしてください。\n\nクリックして切り替え: タイトル",
+ "skip_words_input_tooltip": "特定のコンテンツ(例: WIP、スケッチ、プレビュー)のダウンロードをスキップするには、単語をコンマ区切りで入力します。\n\nこの入力の横にある 'スコープ: [タイプ]' ボタンは、このフィルターの適用方法を変更します:\n- スコープ: ファイル: これらの単語のいずれかを含む場合、個々のファイルをスキップします。\n- スコープ: 投稿: これらの単語のいずれかを含む場合、投稿全体をスキップします。\n- スコープ: 両方: 両方を適用します(最初に投稿タイトル、次に投稿タイトルがOKの場合は個々のファイル)。",
+ "remove_words_input_tooltip": "ダウンロードされたファイル名から削除する単語をコンマ区切りで入力します(大文字と小文字を区別しません)。\n一般的なプレフィックス/サフィックスのクリーンアップに役立ちます。\n例: patreon、kemono、[HD]、_final",
+ "skip_scope_files_text": "スコープ: ファイル",
+ "skip_scope_files_tooltip": "現在のスキップスコープ: ファイル\n\n'スキップワード' のいずれかを含む場合、個々のファイルをスキップします。\n例: スキップワード \"WIP, sketch\"。\n- ファイル \"art_WIP.jpg\" -> スキップ。\n- ファイル \"final_art.png\" -> ダウンロード済み(他の条件が満たされた場合)。\n\n投稿は、スキップされていない他のファイルに対して引き続き処理されます。\nクリックして切り替え: 両方",
+ "skip_scope_posts_text": "スコープ: 投稿",
+ "skip_scope_posts_tooltip": "現在のスキップスコープ: 投稿\n\n'スキップワード' のいずれかを含む場合、投稿全体をスキップします。\nスキップされた投稿のすべてのファイルは無視されます。\n例: スキップワード \"preview, announcement\"。\n- 投稿 \"Exciting Announcement!\" -> スキップ。\n- 投稿 \"Finished Artwork\" -> 処理済み(他の条件が満たされた場合)。\n\nクリックして切り替え: ファイル",
+ "skip_scope_both_text": "スコープ: 両方",
+ "skip_scope_both_tooltip": "現在のスキップスコープ: 両方(投稿、次にファイル)\n\n1. 投稿タイトルを確認します。タイトルにスキップワードが含まれている場合、投稿全体がスキップされます。\n2. 投稿タイトルがOKの場合、次に個々のファイル名を確認します。ファイル名にスキップワードが含まれている場合、そのファイルのみがスキップされます。\n例: スキップワード \"WIP, sketch\"。\n- 投稿 \"Sketches and WIPs\" (タイトル一致) -> 投稿全体がスキップされます。\n- 投稿 \"Art Update\" (タイトルOK) とファイル:\n - \"character_WIP.jpg\" (ファイル一致) -> スキップ。\n - \"final_scene.png\" (ファイルOK) -> ダウンロード済み。\n\nクリックして切り替え: 投稿",
+ "skip_scope_unknown_text": "スコープ: 不明",
+ "skip_scope_unknown_tooltip": "現在のスキップスコープ: 不明\n\nスキップワードのスコープが不明な状態です。サイクルまたはリセットしてください。\n\nクリックして切り替え: 投稿",
+ "language_change_title": "言語が変更されました",
+ "language_change_message": "言語が変更されました。すべての変更を完全に有効にするには、再起動が必要です。",
+ "language_change_informative": "今すぐアプリケーションを再起動しますか?",
+ "restart_now_button": "今すぐ再起動",
+ "skip_zip_checkbox_label": ".zipをスキップ",
+ "skip_rar_checkbox_label": ".rarをスキップ",
+ "download_thumbnails_checkbox_label": "サムネイルのみをダウンロード",
+ "scan_content_images_checkbox_label": "コンテンツをスキャンして画像を探す",
+ "compress_images_checkbox_label": "WebPに圧縮",
+ "separate_folders_checkbox_label": "名前/タイトルでフォルダを分ける",
+ "subfolder_per_post_checkbox_label": "投稿ごとにサブフォルダを作成",
+ "use_cookie_checkbox_label": "Cookieを使用",
+ "use_multithreading_checkbox_base_label": "マルチスレッドを使用",
+ "show_external_links_checkbox_label": "ログに外部リンクを表示",
+ "manga_comic_mode_checkbox_label": "マンガ/コミックモード",
+ "threads_label": "スレッド:",
+ "start_download_button_text": "⬇️ダウンロード開始",
+ "start_download_button_tooltip": "現在の設定でダウンロードまたはリンク抽出プロセスを開始するには、クリックします。",
+ "extract_links_button_text": "🔗リンクを抽出",
+ "pause_download_button_text": "⏸️ダウンロードを一時停止",
+ "pause_download_button_tooltip": "現在実行中のダウンロードプロセスを一時停止するには、クリックします。",
+ "resume_download_button_text": "▶️ダウンロードを再開",
+ "resume_download_button_tooltip": "ダウンロードを再開するには、クリックします。",
+ "cancel_button_text": "❌キャンセルしてUIをリセット",
+ "cancel_button_tooltip": "現在のダウンロード/抽出プロセスをキャンセルし、UIフィールドをリセットするには、クリックします(URLとディレクトリは保持されます)。",
+ "error_button_text": "エラー",
+ "error_button_tooltip": "エラーのためにスキップされたファイルを表示し、オプションで再試行します。",
+ "cancel_retry_button_text": "❌再試行をキャンセル",
+ "known_chars_label_text": "🎭既知のシリーズ/キャラクター(フォルダ名用):",
+ "open_known_txt_button_text": "Known.txtを開く",
+ "known_chars_list_tooltip": "このリストには、'フォルダを分ける'がオンで、特定の'キャラクターでフィルター'が提供されていないか、投稿に一致しない場合に使用される自動フォルダ作成用の名前が含まれています。\n頻繁にダウンロードするシリーズ、ゲーム、またはキャラクターの名前を追加します。",
+ "open_known_txt_button_tooltip": "デフォルトのテキストエディタで'Known.txt'ファイルを開きます。\nファイルはアプリケーションのディレクトリにあります。",
+ "add_char_button_text": "➕追加",
+ "add_char_button_tooltip": "入力フィールドの名前を'既知のシリーズ/キャラクター'リストに追加します。",
+ "add_to_filter_button_text": "⤵️フィルターに追加",
+ "add_to_filter_button_tooltip": "'既知のシリーズ/キャラクター'リストから名前を選択して、上の'キャラクターでフィルター'フィールドに追加します。",
+ "delete_char_button_text": "🗑️選択項目を削除",
+ "delete_char_button_tooltip": "'既知のシリーズ/キャラクター'リストから選択した名前を削除します。",
+ "progress_log_label_text": "📜進捗ログ:",
+ "radio_all_tooltip": "投稿で見つかったすべての種類のファイルをダウンロードします。",
+ "radio_images_tooltip": "一般的な画像形式(JPG、PNG、GIF、WEBPなど)のみをダウンロードします。",
+ "radio_videos_tooltip": "一般的な動画形式(MP4、MKV、WEBM、MOVなど)のみをダウンロードします。",
+ "radio_only_archives_tooltip": ".zipファイルと.rarファイルのみを排他的にダウンロードします。他のファイル固有のオプションは無効になります。",
+ "radio_only_audio_tooltip": "一般的な音声形式(MP3、WAV、FLACなど)のみをダウンロードします。",
+ "radio_only_links_tooltip": "ファイルをダウンロードする代わりに、投稿の説明から外部リンクを抽出して表示します。\nダウンロード関連のオプションは無効になります。",
+ "favorite_mode_checkbox_tooltip": "お気に入りモードを有効にして、保存したアーティスト/投稿から閲覧およびダウンロードします。\nこれにより、URL入力フィールドがお気に入り選択ボタンに置き換えられます。",
+ "skip_zip_checkbox_tooltip": "チェックすると、.zipアーカイブファイルはダウンロードされません。\n('アーカイブのみ'が選択されている場合は無効)。",
+ "skip_rar_checkbox_tooltip": "チェックすると、.rarアーカイブファイルはダウンロードされません。\n('アーカイブのみ'が選択されている場合は無効)。",
+ "download_thumbnails_checkbox_tooltip": "APIからフルサイズのファイルではなく、小さなプレビュー画像をダウンロードします(利用可能な場合)。\n'投稿コンテンツをスキャンして画像URLを探す'もチェックされている場合、このモードはコンテンツスキャンで見つかった画像*のみ*をダウンロードします(APIサムネイルは無視)。",
+ "scan_content_images_checkbox_tooltip": "チェックすると、ダウンローダーは投稿のHTMLコンテンツをスキャンして画像URL(
タグまたは直接リンクから)を探します。\nこれには、
タグからの相対パスを完全なURLに解決することも含まれます。\n
タグの相対パス(例: /data/image.jpg)は完全なURLに解決されます。\n画像が投稿の説明にあるが、APIのファイル/添付ファイルリストにない場合に役立ちます。",
+ "compress_images_checkbox_tooltip": "1.5MBを超える画像をWebP形式に圧縮します(Pillowが必要)。",
+ "use_subfolders_checkbox_tooltip": "'キャラクターでフィルター'入力または投稿タイトルに基づいてサブフォルダを作成します。\n特定のフィルターが一致しない場合、フォルダ名のフォールバックとして'既知のシリーズ/キャラクター'リストを使用します。\n単一投稿の'キャラクターでフィルター'および'カスタムフォルダ名'入力を有効にします。",
+ "use_subfolder_per_post_checkbox_tooltip": "投稿ごとにサブフォルダを作成します。'フォルダを分ける'もオンになっている場合、キャラクター/タイトルフォルダ内に作成されます。",
+ "use_cookie_checkbox_tooltip": "チェックすると、アプリケーションディレクトリにある'cookies.txt'(Netscape形式)のCookieを使用してリクエストを試みます。\nKemono/Coomerでログインが必要なコンテンツにアクセスするのに役立ちます。",
+ "cookie_text_input_tooltip": "Cookie文字列を直接入力します。\nこれは、'Cookieを使用'がチェックされていて、'cookies.txt'が見つからないか、このフィールドが空でない場合に使用されます。\n形式はバックエンドがどのように解析するかに依存します(例: 'name1=value1; name2=value2')。",
+ "use_multithreading_checkbox_tooltip": "同時操作を有効にします。詳細については、'スレッド'フィールドを参照してください。",
+ "thread_count_input_tooltip": "同時操作の数。\n- 単一投稿: 同時ファイルダウンロード(1〜10を推奨)。\n- クリエイターフィードURL: 同時に処理する投稿の数(1〜200を推奨)。\n 各投稿のファイルは、そのワーカーによって1つずつダウンロードされます。\n'マルチスレッドを使用'がオフの場合、1つのスレッドが使用されます。",
+ "external_links_checkbox_tooltip": "チェックすると、メインログの下にセカンダリログパネルが表示され、投稿の説明で見つかった外部リンクが表示されます。\n('リンクのみ'または'アーカイブのみ'モードがアクティブな場合は無効)。",
+ "manga_mode_checkbox_tooltip": "投稿を古いものから新しいものへとダウンロードし、投稿タイトルに基づいてファイルの名前を変更します(クリエイターフィードのみ)。",
+ "multipart_on_button_text": "マルチパート: オン",
+ "multipart_on_button_tooltip": "マルチパートダウンロード: オン\n\n大きなファイルを複数のセグメントで同時にダウンロードできるようにします。\n- 単一の大きなファイルのダウンロードを高速化する場合があります(例: 動画)。\n- CPU/ネットワーク使用量が増加する可能性があります。\n- 多くの小さなファイルがあるフィードの場合、速度の利点が得られない可能性があり、UI/ログがビジーになる可能性があります。\n- マルチパートが失敗した場合、単一ストリームとして再試行します。\n\n無効にするにはクリックします。",
+ "multipart_off_button_text": "マルチパート: オフ",
+ "multipart_off_button_tooltip": "マルチパートダウンロード: オフ\n\nすべてのファイルは単一のストリームを使用してダウンロードされます。\n- 安定しており、ほとんどのシナリオ、特に多くの小さなファイルに適しています。\n- 大きなファイルは順次ダウンロードされます。\n\n有効にするにはクリックします(警告を参照)。",
+ "reset_button_text": "🔄リセット",
+ "reset_button_tooltip": "すべての入力とログをデフォルトの状態にリセットします(アプリがアイドル状態の場合のみ)。",
+ "progress_idle_text": "進捗: アイドル",
+ "missed_character_log_label_text": "🚫見逃したキャラクターログ:",
+ "creator_popup_title": "クリエイター選択",
+ "creator_popup_search_placeholder": "名前、サービスで検索、またはクリエイターURLを貼り付け...",
+ "creator_popup_add_selected_button": "選択項目を追加",
+ "creator_popup_scope_characters_button": "スコープ: キャラクター",
+ "creator_popup_scope_creators_button": "スコープ: クリエイター",
+ "favorite_artists_button_text": "🖼️お気に入りのアーティスト",
+ "favorite_artists_button_tooltip": "Kemono.su/Coomer.suでお気に入りのアーティストから閲覧してダウンロードします。",
+ "favorite_posts_button_text": "📄お気に入りの投稿",
+ "favorite_posts_button_tooltip": "Kemono.su/Coomer.suでお気に入りの投稿から閲覧してダウンロードします。",
+ "favorite_scope_selected_location_text": "スコープ: 選択した場所",
+ "favorite_scope_selected_location_tooltip": "現在のお気に入りダウンロードスコープ: 選択した場所\n\n選択したすべてのお気に入りのアーティスト/投稿は、UIで指定したメインの'ダウンロード場所'にダウンロードされます。\nフィルター(キャラクター、スキップワード、ファイルタイプ)は、すべてのコンテンツにグローバルに適用されます。\n\nアーティストフォルダに切り替えるにはクリックします",
+ "favorite_scope_artist_folders_text": "スコープ: アーティストフォルダ",
+ "favorite_scope_artist_folders_tooltip": "現在のお気に入りダウンロードスコープ: アーティストフォルダ\n\n選択したお気に入りのアーティスト/投稿ごとに、メインの'ダウンロード場所'内に新しいサブフォルダ(アーティスト名で命名)が作成されます。\nそのアーティスト/投稿のコンテンツは、特定のサブフォルダにダウンロードされます。\nフィルター(キャラクター、スキップワード、ファイルタイプ)は、各アーティストのフォルダ*内*に適用されます。\n\n選択した場所に切り替えるにはクリックします",
+ "favorite_scope_unknown_text": "スコープ: 不明",
+ "favorite_scope_unknown_tooltip": "お気に入りのダウンロードスコープが不明です。サイクルするにはクリックします。",
+ "manga_style_post_title_text": "名前: 投稿タイトル",
+ "manga_style_original_file_text": "名前: オリジナルファイル",
+ "manga_style_date_based_text": "名前: 日付ベース",
+ "manga_style_title_global_num_text": "名前: タイトル+G.Num",
+ "manga_style_unknown_text": "名前: 不明なスタイル",
+ "fav_artists_dialog_title": "お気に入りのアーティスト",
+ "fav_artists_loading_status": "お気に入りのアーティストを読み込んでいます...",
+ "fav_artists_search_placeholder": "アーティストを検索...",
+ "fav_artists_select_all_button": "すべて選択",
+ "fav_artists_deselect_all_button": "すべて選択解除",
+ "fav_artists_download_selected_button": "選択項目をダウンロード",
+ "fav_artists_cancel_button": "キャンセル",
+ "fav_artists_loading_from_source_status": "⏳{source_name}からお気に入りを読み込んでいます...",
+ "fav_artists_found_status": "合計で{count}人のお気に入りのアーティストが見つかりました。",
+ "fav_artists_none_found_status": "Kemono.suまたはCoomer.suにお気に入りのアーティストが見つかりませんでした。",
+ "fav_artists_failed_status": "お気に入りの取得に失敗しました。",
+ "fav_artists_cookies_required_status": "エラー: Cookieが有効ですが、どのソースからも読み込めませんでした。",
+ "fav_artists_no_favorites_after_processing": "処理後にお気に入りのアーティストが見つかりませんでした。",
+ "fav_artists_no_selection_title": "選択なし",
+ "fav_artists_no_selection_message": "ダウンロードするアーティストを少なくとも1人選択してください。",
+ "fav_posts_dialog_title": "お気に入りの投稿",
+ "fav_posts_loading_status": "お気に入りの投稿を読み込んでいます...",
+ "fav_posts_search_placeholder": "投稿を検索(タイトル、クリエイター、ID、サービス)...",
+ "fav_posts_select_all_button": "すべて選択",
+ "fav_posts_deselect_all_button": "すべて選択解除",
+ "fav_posts_download_selected_button": "選択項目をダウンロード",
+ "fav_posts_cancel_button": "キャンセル",
+ "fav_posts_cookies_required_error": "エラー: お気に入りの投稿にはCookieが必要ですが、読み込めませんでした。",
+ "fav_posts_auth_failed_title": "認証に失敗しました(投稿)",
+ "fav_posts_auth_failed_message": "認証エラーのため、{domain_specific_part}のお気に入りを取得できませんでした:\n\n{error_message}\n\nこれは通常、サイトのCookieがない、無効、または期限切れであることを意味します。Cookieの設定を確認してください。",
+ "fav_posts_fetch_error_title": "フェッチエラー",
+ "fav_posts_fetch_error_message": "{domain}{error_message_part}からお気に入りをフェッチ中にエラーが発生しました",
+ "fav_posts_no_posts_found_status": "お気に入りの投稿が見つかりませんでした。",
+ "fav_posts_found_status": "{count}件のお気に入りの投稿が見つかりました。",
+ "fav_posts_display_error_status": "投稿の表示エラー: {error}",
+ "fav_posts_ui_error_title": "UIエラー",
+ "fav_posts_ui_error_message": "お気に入りの投稿を表示できませんでした: {error}",
+ "fav_posts_auth_failed_message_generic": "認証エラーのため、{domain_specific_part}のお気に入りを取得できませんでした。これは通常、サイトのCookieがない、無効、または期限切れであることを意味します。Cookieの設定を確認してください。",
+ "key_fetching_fav_post_list_init": "お気に入りの投稿リストをフェッチしています...",
+ "key_fetching_from_source_kemono_su": "Kemono.suからお気に入りをフェッチしています...",
+ "key_fetching_from_source_coomer_su": "Coomer.suからお気に入りをフェッチしています...",
+ "fav_posts_fetch_cancelled_status": "お気に入りの投稿のフェッチがキャンセルされました。",
+ "known_names_filter_dialog_title": "既知の名前をフィルターに追加",
+ "known_names_filter_search_placeholder": "名前を検索...",
+ "known_names_filter_select_all_button": "すべて選択",
+ "known_names_filter_deselect_all_button": "すべて選択解除",
+ "known_names_filter_add_selected_button": "選択項目を追加",
+ "error_files_dialog_title": "エラーのためにスキップされたファイル",
+ "error_files_no_errors_label": "前回のセッションまたは再試行後にエラーのためにスキップされたと記録されたファイルはありません。",
+ "error_files_found_label": "次の{count}個のファイルは、ダウンロードエラーのためにスキップされました:",
+ "error_files_select_all_button": "すべて選択",
+ "error_files_retry_selected_button": "選択項目を再試行",
+ "error_files_export_urls_button": "URLを.txtにエクスポート",
+ "error_files_no_selection_retry_message": "再試行するファイルを少なくとも1つ選択してください。",
+ "error_files_no_errors_export_title": "エラーなし",
+ "error_files_no_errors_export_message": "エクスポートするエラーのあるファイルURLはありません。",
+ "error_files_no_urls_found_export_title": "URLが見つかりません",
+ "error_files_no_urls_found_export_message": "エラーのあるファイルのリストからエクスポートするURLを抽出できませんでした。",
+ "error_files_save_dialog_title": "エラーのあるファイルURLを保存",
+ "error_files_export_success_title": "エクスポートに成功しました",
+ "error_files_export_success_message": "{count}個のエントリが正常にエクスポートされました:\n{filepath}",
+ "error_files_export_error_title": "エクスポートエラー",
+ "error_files_export_error_message": "ファイルリンクをエクスポートできませんでした: {error}",
+ "export_options_dialog_title": "エクスポートオプション",
+ "export_options_description_label": "エラーのあるファイルリンクのエクスポート形式を選択してください:",
+ "export_options_radio_link_only": "1行に1リンク(URLのみ)",
+ "export_options_radio_link_only_tooltip": "失敗したファイルごとに直接ダウンロードURLのみをエクスポートし、1行に1つのURLをエクスポートします。",
+ "export_options_radio_with_details": "詳細付きでエクスポート(URL [投稿、ファイル情報])",
+ "export_options_radio_with_details_tooltip": "URLの後に、投稿タイトル、投稿ID、元のファイル名などの詳細を角括弧で囲んでエクスポートします。",
+ "export_options_export_button": "エクスポート",
+ "no_errors_logged_title": "記録されたエラーはありません",
+ "no_errors_logged_message": "前回のセッションまたは再試行後にエラーのためにスキップされたと記録されたファイルはありません。",
+ "progress_initializing_text": "進捗: 初期化中...",
+ "progress_posts_text": "進捗: {processed_posts} / {total_posts} 投稿 ({progress_percent:.1f}%)",
+ "progress_processing_post_text": "進捗: 投稿 {processed_posts} を処理中...",
+ "progress_starting_text": "進捗: 開始中...",
+ "downloading_file_known_size_text": "'{filename}' をダウンロード中 ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)",
+ "downloading_file_unknown_size_text": "'{filename}' をダウンロード中 ({downloaded_mb:.1f}MB)",
+ "downloading_multipart_text": "DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts} parts @ {speed:.2f} MB/s)",
+ "downloading_multipart_initializing_text": "ファイル: {filename} - パーツを初期化中...",
+ "status_completed": "完了",
+ "status_cancelled_by_user": "ユーザーによってキャンセルされました",
+ "files_downloaded_label": "ダウンロード済み",
+ "files_skipped_label": "スキップ済み",
+ "retry_finished_text": "再試行が完了しました",
+ "succeeded_text": "成功",
+ "failed_text": "失敗",
+ "ready_for_new_task_text": "新しいタスクの準備ができました。",
+ "fav_mode_active_label_text": "⭐お気に入りモードがアクティブです。お気に入りのアーティスト/投稿を選択する前に、以下のフィルターを選択してください。以下のアクションを選択してください。",
+ "export_links_button_text": "リンクをエクスポート",
+ "download_extracted_links_button_text": "ダウンロード",
+ "download_selected_button_text": "選択項目をダウンロード",
+ "link_input_placeholder_text": "例: https://kemono.su/patreon/user/12345 または .../post/98765",
+ "link_input_tooltip_text": "Kemono/Coomerクリエイターページまたは特定の投稿の完全なURLを入力します。\n例(クリエイター): https://kemono.su/patreon/user/12345\n例(投稿): https://kemono.su/patreon/user/12345/post/98765",
+ "dir_input_placeholder_text": "ダウンロードを保存するフォルダを選択してください",
+ "dir_input_tooltip_text": "ダウンロードしたすべてのコンテンツを保存するメインフォルダを入力または参照します。\n'リンクのみ'モードが選択されていない限り、これは必須です。",
+ "character_input_placeholder_text": "例: Tifa, Aerith, (Cloud, Zack)",
+ "custom_folder_input_placeholder_text": "オプション: この投稿を特定のフォルダに保存",
+ "custom_folder_input_tooltip_text": "単一の投稿URLをダウンロードしていて、'名前/タイトルでフォルダを分ける'が有効になっている場合は、\nこの投稿のダウンロードフォルダにカスタム名を入力できます。\n例: My Favorite Scene",
+ "skip_words_input_placeholder_text": "例: WM, WIP, sketch, preview",
+ "remove_from_filename_input_placeholder_text": "例: patreon, HD",
+ "cookie_text_input_placeholder_no_file_selected_text": "Cookie文字列(cookies.txtが選択されていない場合)",
+ "cookie_text_input_placeholder_with_file_selected_text": "選択したCookieファイルを使用しています(参照...を参照)",
+ "character_search_input_placeholder_text": "キャラクターを検索...",
+ "character_search_input_tooltip_text": "ここに入力して、下の既知のシリーズ/キャラクターのリストをフィルタリングします。",
+ "new_char_input_placeholder_text": "新しいシリーズ/キャラクター名を追加",
+ "new_char_input_tooltip_text": "上のリストに追加する新しいシリーズ、ゲーム、またはキャラクター名を入力します。",
+ "link_search_input_placeholder_text": "リンクを検索...",
+ "link_search_input_tooltip_text": "'リンクのみ'モードでは、ここに入力して、表示されるリンクをテキスト、URL、またはプラットフォームでフィルタリングします。",
+ "manga_date_prefix_input_placeholder_text": "マンガのファイル名のプレフィックス",
+ "manga_date_prefix_input_tooltip_text": "'日付ベース'または'オリジナルファイル'のマンガのファイル名のオプションのプレフィックス(例: 'シリーズ名')。\n空の場合、ファイルはプレフィックスなしでスタイルに従って名前が付けられます。",
+ "log_display_mode_links_view_text": "🔗リンクビュー",
+ "log_display_mode_progress_view_text": "⬇️進捗ビュー",
+ "download_external_links_dialog_title": "選択した外部リンクをダウンロード",
+ "select_all_button_text": "すべて選択",
+ "deselect_all_button_text": "すべて選択解除",
+ "cookie_browse_button_tooltip": "Cookieファイル(Netscape形式、通常はcookies.txt)を参照します。\nこれは、'Cookieを使用'がチェックされていて、上のテキストフィールドが空の場合に使用されます。",
+ "page_range_label_text": "ページ範囲:",
+ "start_page_input_placeholder": "開始",
+ "start_page_input_tooltip": "クリエイターURLの場合: ダウンロードの開始ページ番号を指定します(例: 1、2、3)。\n最初のページから開始するには、空にするか、1に設定します。\n単一の投稿URLまたはマンガ/コミックモードでは無効になります。",
+ "page_range_to_label_text": "から",
+ "end_page_input_placeholder": "終了",
+ "end_page_input_tooltip": "クリエイターURLの場合: ダウンロードの終了ページ番号を指定します(例: 5、10)。\n開始ページからすべてのページをダウンロードするには、空にします。\n単一の投稿URLまたはマンガ/コミックモードでは無効になります。",
+ "known_names_help_button_tooltip_text": "アプリケーション機能ガイドを開きます。",
+ "future_settings_button_tooltip_text": "アプリケーション設定(テーマ、言語など)を開きます。",
+ "link_search_button_tooltip_text": "表示されるリンクをフィルタリング",
+ "confirm_add_all_dialog_title": "新しい名前の追加を確認",
+ "confirm_add_all_info_label": "'キャラクターでフィルター'入力の次の新しい名前/グループは'Known.txt'にありません。\n追加すると、将来のダウンロードのフォルダ構成が改善される可能性があります。\n\nリストを確認して、アクションを選択してください:",
+ "confirm_add_all_select_all_button": "すべて選択",
+ "confirm_add_all_deselect_all_button": "すべて選択解除",
+ "confirm_add_all_add_selected_button": "選択項目をKnown.txtに追加",
+ "confirm_add_all_skip_adding_button": "これらの追加をスキップ",
+ "confirm_add_all_cancel_download_button": "ダウンロードをキャンセル",
+ "cookie_help_dialog_title": "Cookieファイルの説明",
+ "cookie_help_instruction_intro": "Cookieを使用するには、通常、ブラウザからcookies.txtファイルが必要です。
",
+ "cookie_help_how_to_get_title": "cookies.txtの入手方法:
",
+ "cookie_help_step1_extension_intro": "Chromeベースのブラウザに'Get cookies.txt LOCALLY'拡張機能をインストールします:
ChromeウェブストアでGet cookies.txt LOCALLYを入手",
+ "cookie_help_step2_login": "ウェブサイト(例: kemono.suまたはcoomer.su)にアクセスし、必要に応じてログインします。",
+ "cookie_help_step3_click_icon": "ブラウザのツールバーで拡張機能のアイコンをクリックします。",
+ "cookie_help_step4_export": "'エクスポート'ボタン(例: \"エクスポートとして\"、\"cookies.txtをエクスポート\" - 正確な文言は拡張機能のバージョンによって異なる場合があります)をクリックします。",
+ "cookie_help_step5_save_file": "ダウンロードしたcookies.txtファイルをコンピュータに保存します。",
+ "cookie_help_step6_app_intro": "このアプリケーションで:",
+ "cookie_help_step6a_checkbox": "- 'Cookieを使用'ボックスがチェックされていることを確認します。
",
+ "cookie_help_step6b_browse": "- Cookieテキストフィールドの横にある'参照...'ボタンをクリックします。
",
+ "cookie_help_step6c_select": "- 保存した
cookies.txtファイルを選択します。
",
+ "cookie_help_alternative_paste": "または、一部の拡張機能では、Cookie文字列を直接コピーできます。その場合は、ファイルを参照する代わりにテキストフィールドに貼り付けることができます。
",
+ "cookie_help_proceed_without_button": "Cookieなしでダウンロード",
+ "cookie_help_cancel_download_button": "ダウンロードをキャンセル",
+ "character_input_tooltip": "キャラクター名を入力します(コンマ区切り)。高度なグループ化をサポートし、'フォルダを分ける'が有効になっている場合はフォルダの命名に影響します。\n\n例:\n- Nami → 'Nami'に一致し、'Nami'フォルダを作成します。\n- (Ulti, Vivi) → いずれかに一致し、'Ulti Vivi'フォルダを作成し、両方をKnown.txtに個別に追加します。\n- (Boa, Hancock)~ → いずれかに一致し、'Boa Hancock'フォルダを作成し、1つのグループとしてKnown.txtに追加します。\n\n名前は一致のエイリアスとして扱われます。\n\nフィルターモード(ボタンサイクル):\n- ファイル: ファイル名でフィルタリングします。\n- タイトル: 投稿タイトルでフィルタリングします。\n- 両方: 最初にタイトル、次にファイル名。\n- コメント(ベータ): 最初にファイル名、次に投稿コメント。",
+ "tour_dialog_title": "Kemono Downloaderへようこそ!",
+ "tour_dialog_never_show_checkbox": "このツアーを二度と表示しない",
+ "tour_dialog_skip_button": "ツアーをスキップ",
+ "tour_dialog_back_button": "戻る",
+ "tour_dialog_next_button": "次へ",
+ "tour_dialog_finish_button": "完了",
+ "tour_dialog_step1_title": "👋ようこそ!",
+ "tour_dialog_step1_content": "こんにちは!このクイックツアーでは、強化されたフィルタリング、マンガモードの改善、Cookieの処理などの最近の更新を含む、Kemono Downloaderの主な機能について説明します。\n\n- 私の目標は、KemonoとCoomerからコンテンツを簡単にダウンロードできるようにすることです。
\n- 🎨クリエイター選択ボタン: URL入力の横にあるパレットアイコンをクリックしてダイアログを開きます。
creators.jsonファイルからクリエイターを閲覧して選択し、URL入力に名前をすばやく追加します。
\n- 重要なヒント: アプリが'(応答なし)'になりますか?
\n'ダウンロード開始'をクリックした後、特に大規模なクリエイターフィードや多くのスレッドがある場合、アプリが一時的に'(応答なし)'と表示されることがあります。お使いのオペレーティングシステム(Windows、macOS、Linux)によっては、'プロセスを終了'または'強制終了'を提案することさえあります。
\nしばらくお待ちください!アプリは多くの場合、バックグラウンドで一生懸命動作しています。強制終了する前に、ファイルエクスプローラーで選択した'ダウンロード場所'を確認してみてください。新しいフォルダが作成されたり、ファイルが表示されたりする場合は、ダウンロードが正常に進行していることを意味します。応答するようになるまで、しばらく時間をおいてください。
\n- 次へボタンと戻るボタンを使用してナビゲートします。
\n- 多くのオプションには、詳細についてはホバーするとツールチップが表示されます。
\n- いつでもこのガイドを閉じるには、ツアーをスキップをクリックします。
\n- 今後の起動時にこれを表示したくない場合は、'このツアーを二度と表示しない'をチェックします。
\n
",
+ "tour_dialog_step2_title": "①はじめに",
+ "tour_dialog_step2_content": "ダウンロードの基本から始めましょう:\n\n- 🔗クリエイター/投稿Kemono URL:
\nクリエイターのページの完全なWebアドレス(URL)を貼り付けます(例: https://kemono.su/patreon/user/12345)\nまたは特定の投稿(例: .../post/98765)。
\nまたはCoomerクリエイター(例: https://coomer.su/onlyfans/user/artistname)
\n- 📁ダウンロード場所:
\n'参照...'をクリックして、ダウンロードしたすべてのファイルを保存するコンピュータ上のフォルダを選択します。\n'リンクのみ'モードを使用していない限り、これは必須です。
\n- 📄ページ範囲(クリエイターURLのみ):
\nクリエイターのページからダウンロードする場合、取得するページの範囲を指定できます(例: 2〜5ページ)。\nすべてのページの場合は空白のままにします。これは、単一の投稿URLまたはマンガ/コミックモードがアクティブな場合は無効になります。 \n
",
+ "tour_dialog_step3_title": "②ダウンロードのフィルタリング",
+ "tour_dialog_step3_content": "これらのフィルターでダウンロードするものを絞り込みます('リンクのみ'または'アーカイブのみ'モードではほとんどが無効になります):\n\n- 🎯キャラクターでフィルター:
\nキャラクター名をコンマで区切って入力します(例: Tifa, Aerith)。結合されたフォルダ名のエイリアスをグループ化します: (alias1, alias2, alias3) は、(クリーンアップ後) 'alias1 alias2 alias3' フォルダになります。グループ内のすべての名前は、照合のエイリアスとして使用されます。
\nこの入力の横にある'フィルター: [タイプ]'ボタンは、このフィルターの適用方法を変更します:\n- フィルター: ファイル: 個々のファイル名をチェックします。いずれかのファイルが一致した場合、投稿は保持されます。一致したファイルのみがダウンロードされます。フォルダの命名は、一致したファイル名のキャラクターを使用します('フォルダを分ける'がオンの場合)。
\n- フィルター: タイトル: 投稿タイトルをチェックします。一致した投稿のすべてのファイルがダウンロードされます。フォルダの命名は、一致した投稿タイトルのキャラクターを使用します。
\n- ⤵️フィルターに追加ボタン(既知の名前): 既知の名前の'追加'ボタンの横に(ステップ5を参照)、これによりポップアップが開きます。チェックボックス(検索バー付き)を介して
Known.txtリストから名前を選択し、'キャラクターでフィルター'フィールドにすばやく追加します。Known.txtから(Boa, Hancock)のようなグループ化された名前は、フィルターに(Boa, Hancock)~として追加されます。
\n- フィルター: 両方: 最初に投稿タイトルをチェックします。一致した場合、すべてのファイルがダウンロードされます。そうでない場合は、ファイル名をチェックし、一致したファイルのみがダウンロードされます。フォルダの命名は、タイトルの一致を優先し、次にファイルの一致を優先します。
\n- フィルター: コメント(ベータ): 最初にファイル名をチェックします。ファイルが一致した場合、投稿内のすべてのファイルがダウンロードされます。ファイルの一致がない場合は、投稿のコメントをチェックします。コメントが一致した場合、すべてのファイルがダウンロードされます。(より多くのAPIリクエストを使用します)。フォルダの命名は、ファイルの一致を優先し、次にコメントの一致を優先します。
\nこのフィルターは、'名前/タイトルでフォルダを分ける'がオンの場合にもフォルダの命名に影響します。
\n- 🚫単語でスキップ:
\n単語をコンマで区切って入力します(例: WIP, sketch, preview)。\nこの入力の横にある'スコープ: [タイプ]'ボタンは、このフィルターの適用方法を変更します:\n- スコープ: ファイル: これらの単語のいずれかを含む場合、ファイルをスキップします。
\n- スコープ: 投稿: これらの単語のいずれかを含む場合、投稿全体をスキップします。
\n- スコープ: 両方: ファイルと投稿タイトルの両方のスキップを適用します(最初に投稿、次にファイル)。
\n- ファイルをフィルター(ラジオボタン): ダウンロードするものを選択します:\n
\n- すべて: 見つかったすべての種類のファイルをダウンロードします。
\n- 画像/GIF: 一般的な画像形式とGIFのみ。
\n- 動画: 一般的な動画形式のみ。
\n- 📦アーカイブのみ: .zipファイルと.rarファイルのみを排他的にダウンロードします。これを選択すると、'zipをスキップ'および'rarをスキップ'チェックボックスが自動的に無効になり、オフになります。'外部リンクを表示'も無効になります。
\n- 🎧音声のみ: 一般的な音声形式(MP3、WAV、FLACなど)のみ。
\n- 🔗リンクのみ: ファイルをダウンロードする代わりに、投稿の説明から外部リンクを抽出して表示します。ダウンロード関連のオプションと'外部リンクを表示'は無効になります。
\n
\n
",
+ "tour_dialog_step4_title": "③お気に入りモード(代替ダウンロード)",
+ "tour_dialog_step4_content": "このアプリは、Kemono.suでお気に入りに登録したアーティストからコンテンツをダウンロードするための'お気に入りモード'を提供します。\n\n- ⭐お気に入りモードチェックボックス:
\n'🔗リンクのみ'ラジオボタンの横にあります。このボックスをチェックして、お気に入りモードを有効にします。
\n- お気に入りモードで何が起こるか:\n
- '🔗クリエイター/投稿Kemono URL'入力領域が、お気に入りモードがアクティブであることを示すメッセージに置き換えられます。
\n- 標準の'ダウンロード開始'、'一時停止'、'キャンセル'ボタンが、'🖼️お気に入りのアーティスト'ボタンと'📄お気に入りの投稿'ボタンに置き換えられます(注: 'お気に入りの投稿'は将来予定されています)。
\n- お気に入りを取得するにはCookieが必要なため、'🍪Cookieを使用'オプションが自動的に有効になり、ロックされます。
\n- 🖼️お気に入りのアーティストボタン:
\nこれをクリックすると、Kemono.suのお気に入りのアーティストを一覧表示するダイアログが開きます。1人または複数のアーティストを選択してダウンロードできます。
\n- お気に入りのダウンロードスコープ(ボタン):
\nこのボタン('お気に入りの投稿'の横)は、選択したお気に入りをどこにダウンロードするかを制御します:\n- スコープ: 選択した場所: 選択したすべてのアーティストが、設定したメインの'ダウンロード場所'にダウンロードされます。フィルターはグローバルに適用されます。
\n- スコープ: アーティストフォルダ: 選択したアーティストごとに、メインの'ダウンロード場所'にサブフォルダ(アーティスト名で命名)が作成されます。そのアーティストのコンテンツは、特定のフォルダに保存されます。フィルターは各アーティストのフォルダ内に適用されます。
\n- お気に入りモードのフィルター:
\n'キャラクターでフィルター'、'単語でスキップ'、および'ファイルをフィルター'オプションは、選択したお気に入りのアーティストからダウンロードしたコンテンツに引き続き適用されます。 \n
",
+ "tour_dialog_step5_title": "④ダウンロードの絞り込み",
+ "tour_dialog_step5_content": "ダウンロードをカスタマイズするためのその他のオプション:\n\n- .zipをスキップ/.rarをスキップ: これらのアーカイブファイルの種類をダウンロードしないようにするには、これらをチェックします。\n(注: '📦アーカイブのみ'フィルターモードが選択されている場合、これらは無効になり、無視されます)。
\n- ✂️名前から単語を削除:
\nダウンロードされたファイル名から削除する単語をコンマで区切って入力します(例: patreon, [HD])(大文字と小文字を区別しません)。
\n- サムネイルのみをダウンロード: フルサイズのファイルではなく、小さなプレビュー画像をダウンロードします(利用可能な場合)。
\n- 大きな画像を圧縮: 'Pillow'ライブラリがインストールされている場合、1.5MBを超える画像は、WebPバージョンが大幅に小さい場合にWebP形式に変換されます。
\n- 🗄️カスタムフォルダ名(単一投稿のみ):
\n特定の投稿URLをダウンロードしていて、'名前/タイトルでフォルダを分ける'が有効になっている場合は、\nその投稿のダウンロードフォルダにカスタム名を入力できます。
\n- 🍪Cookieを使用: リクエストにCookieを使用するには、これをチェックします。次のいずれかを実行できます:\n
- Cookie文字列をテキストフィールドに直接入力します(例: name1=value1; name2=value2)。
\n- '参照...'をクリックして、cookies.txtファイル(Netscape形式)を選択します。パスがテキストフィールドに表示されます。
\nこれは、ログインが必要なコンテンツにアクセスするのに役立ちます。テキストフィールドが入力されている場合は、テキストフィールドが優先されます。\n'Cookieを使用'がチェックされているが、テキストフィールドと参照ファイルの両方が空の場合、アプリのディレクトリから'cookies.txt'を読み込もうとします。 \n
",
+ "tour_dialog_step6_title": "⑤整理とパフォーマンス",
+ "tour_dialog_step6_content": "ダウンロードを整理し、パフォーマンスを管理します:\n\n- ⚙️名前/タイトルでフォルダを分ける: 'キャラクターでフィルター'入力または投稿タイトルに基づいてサブフォルダを作成します(フォルダ名のフォールバックとしてKnown.txtリストを使用できます)。
\n- 投稿ごとにサブフォルダを作成: 'フォルダを分ける'がオンの場合、これにより、メインのキャラクター/タイトルフォルダ内に各個別の投稿の追加のサブフォルダが作成されます。
\n- 🚀マルチスレッドを使用(スレッド): より高速な操作を有効にします。'スレッド'入力の数値の意味:\n
- クリエイターフィードの場合: 同時に処理する投稿の数。各投稿のファイルは、そのワーカーによって順次ダウンロードされます('日付ベース'のマンガの命名がオンで、1つの投稿ワーカーを強制する場合を除く)。
\n- 単一投稿URLの場合: その単一投稿から同時にダウンロードするファイルの数。
\nオフの場合、1つのスレッドが使用されます。スレッド数が多い場合(例: >40)、警告が表示されることがあります。
\n- マルチパートダウンロードの切り替え(ログ領域の右上):
\n'マルチパート: [オン/オフ]'ボタンは、個々の大きなファイルのマルチセグメントダウンロードを有効/無効にします。\n- オン: 大きなファイルのダウンロードを高速化する場合があります(例: 動画)が、UIの途切れや、多くの小さなファイルでログがスパムになる可能性があります。有効にすると警告が表示されます。マルチパートダウンロードが失敗した場合、単一ストリームとして再試行します。
\n- オフ(デフォルト): ファイルは単一のストリームでダウンロードされます。
\n'リンクのみ'または'アーカイブのみ'モードがアクティブな場合は無効になります。
\n- 📖マンガ/コミックモード(クリエイターURLのみ): 順次コンテンツ用に設計されています。\n
\n- 投稿を古いものから新しいものへとダウンロードします。
\n- すべての投稿がフェッチされるため、'ページ範囲'入力は無効になります。
\n- このモードがクリエイターフィードでアクティブな場合、ログ領域の右上にファイル名スタイルの切り替えボタン(例: '名前: 投稿タイトル')が表示されます。これをクリックして、命名スタイルを切り替えます:\n
\n- 名前: 投稿タイトル(デフォルト): 投稿の最初のファイルは、クリーンアップされた投稿タイトルにちなんで名前が付けられます(例: 'My Chapter 1.jpg')。*同じ投稿*の後続のファイルは、元のファイル名を保持しようとします(例: 'page_02.png'、'bonus_art.jpg')。投稿にファイルが1つしかない場合は、投稿タイトルにちなんで名前が付けられます。これは、ほとんどのマンガ/コミックに一般的に推奨されます。
\n- 名前: オリジナルファイル: すべてのファイルは、元のファイル名を保持しようとします。オプションのプレフィックス(例: 'MySeries_')は、スタイルボタンの横に表示される入力フィールドに入力できます。例: 'MySeries_OriginalFile.jpg'。
\n- 名前: タイトル+G.Num(投稿タイトル+グローバル番号付け): 現在のダウンロードセッションのすべての投稿のすべてのファイルは、クリーンアップされた投稿タイトルをプレフィックスとして使用し、その後にグローバルカウンターを付けて順次名前が付けられます。例: 投稿 'Chapter 1' (2ファイル) -> 'Chapter 1_001.jpg'、'Chapter 1_002.png'。次の投稿 'Chapter 2' (1ファイル) は、番号付けを続けます -> 'Chapter 2_003.jpg'。このスタイルの場合、正しいグローバル番号付けを保証するために、投稿処理のマルチスレッドは自動的に無効になります。
\n- 名前: 日付ベース: ファイルは、投稿の公開順に基づいて順次名前が付けられます(001.ext、002.ext、...)。オプションのプレフィックス(例: 'MySeries_')は、スタイルボタンの横に表示される入力フィールドに入力できます。例: 'MySeries_001.jpg'。このスタイルの場合、投稿処理のマルチスレッドは自動的に無効になります。
\n
\n
\n- '名前: 投稿タイトル'、'名前: タイトル+G.Num'、または'名前: 日付ベース'のスタイルで最良の結果を得るには、フォルダ構成にマンガ/シリーズのタイトルを付けて'キャラクターでフィルター'フィールドを使用します。
\n
\n- 🎭スマートなフォルダ構成のためのKnown.txt:
\nKnown.txt(アプリディレクトリ内)は、'名前/タイトルでフォルダを分ける'がオンの場合の自動フォルダ構成をきめ細かく制御できます。\n\n- 仕組み:
Known.txtの各行はエントリです。\nMy Awesome Seriesのような単純な行は、一致するコンテンツが\"My Awesome Series\"という名前のフォルダに保存されることを意味します。
\n(Character A, Char A, Alt Name A)のようなグループ化された行は、\"Character A\"、\"Char A\"、または\"Alt Name A\"に一致するコンテンツがすべて、(クリーンアップ後)\"Character A Char A Alt Name A\"という名前の単一のフォルダに保存されることを意味します。括弧内のすべての用語は、そのフォルダのエイリアスになります。
\n- スマートフォールバック: '名前/タイトルでフォルダを分ける'がオンで、投稿が特定の'キャラクターでフィルター'エントリに一致しない場合、ダウンローダーは
Known.txtを参照して、フォルダ作成に一致するマスター名を見つけます。
\n- ユーザーフレンドリーな管理: 以下のUIリストを介して単純な(グループ化されていない)名前を追加します。高度な編集(グループ化されたエイリアスの作成/変更など)の場合は、'Known.txtを開く'をクリックして、テキストエディタでファイルを編集します。アプリは、次回の使用時または次回の起動時に再読み込みします。
\n
\n \n
",
+ "tour_dialog_step7_title": "⑥よくあるエラーとトラブルシューティング",
+ "tour_dialog_step7_content": "ダウンロードで問題が発生することがあります。最も一般的なものをいくつか紹介します:\n\n- キャラクター入力ツールチップ:
\nキャラクター名をコンマで区切って入力します(例: Tifa, Aerith)。
\n結合されたフォルダ名のエイリアスをグループ化します: (alias1, alias2, alias3) は、'alias1 alias2 alias3'フォルダになります。
\nグループ内のすべての名前は、コンテンツ照合のエイリアスとして使用されます。
\nこの入力の横にある'フィルター: [タイプ]'ボタンは、このフィルターの適用方法を変更します:
\n- フィルター: ファイル: 個々のファイル名をチェックします。一致したファイルのみがダウンロードされます。
\n- フィルター: タイトル: 投稿タイトルをチェックします。一致した投稿のすべてのファイルがダウンロードされます。
\n- フィルター: 両方: 最初に投稿タイトルをチェックします。一致しない場合は、ファイル名をチェックします。
\n- フィルター: コメント(ベータ): 最初にファイル名をチェックします。一致しない場合は、投稿コメントをチェックします。
\nこのフィルターは、'名前/タイトルでフォルダを分ける'が有効になっている場合にもフォルダの命名に影響します。
\n- 502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout:
\nこれらは通常、Kemono/Coomerのサーバー側の一次的な問題を示します。サイトが過負荷になっているか、メンテナンス中であるか、問題が発生している可能性があります。
\n解決策: しばらく(例: 30分から数時間)待ってから、後でもう一度試してください。ブラウザでサイトを直接確認してください。
\n- 接続が失われました / 接続が拒否されました / タイムアウト(ファイルダウンロード中):
\nこれは、インターネット接続、サーバーの不安定性、またはサーバーが大きなファイルの接続を切断した場合に発生する可能性があります。
\n解決策: インターネットを確認してください。'スレッド'数が高い場合は、減らしてみてください。アプリは、セッションの最後に失敗した一部のファイルを再試行することを提案する場合があります。
\n- IncompleteReadエラー:
\nサーバーが予期したよりも少ないデータを送信しました。多くの場合、一時的なネットワークの不具合またはサーバーの問題です。
\n解決策: アプリは多くの場合、ダウンロードセッションの最後にこれらのファイルを再試行するようにマークします。
\n- 403 Forbidden / 401 Unauthorized(公開投稿ではあまり一般的ではありません):
\nコンテンツにアクセスする権限がない可能性があります。一部の有料またはプライベートコンテンツについては、ブラウザセッションの有効なCookieで'Cookieを使用'オプションを使用すると役立つ場合があります。Cookieが最新であることを確認してください。
\n- 404 Not Found:
\n投稿またはファイルのURLが正しくないか、コンテンツがサイトから削除されています。URLを再確認してください。
\n- '投稿が見つかりません' / 'ターゲット投稿が見つかりません':
\nURLが正しく、クリエイター/投稿が存在することを確認してください。ページ範囲を使用している場合は、クリエイターに対して有効であることを確認してください。非常に新しい投稿の場合、APIに表示されるまでにわずかな遅延がある場合があります。
\n- 全体的な低速 / アプリ'(応答なし)':
\nステップ1で述べたように、特に大規模なクリエイターフィードや多くのスレッドがある場合に起動後にアプリがフリーズするように見える場合は、しばらく時間をおいてください。バックグラウンドでデータを処理している可能性があります。これが頻繁に発生する場合は、スレッド数を減らすと応答性が向上することがあります。 \n
",
+ "tour_dialog_step8_title": "⑦ログと最終制御",
+ "tour_dialog_step8_content": "監視と制御:\n\n- 📜進捗ログ/抽出されたリンクのログ: 詳細なダウンロードメッセージを表示します。'🔗リンクのみ'モードがアクティブな場合、この領域には抽出されたリンクが表示されます。
\n- ログに外部リンクを表示: チェックすると、メインログの下にセカンダリログパネルが表示され、投稿の説明で見つかった外部リンクが表示されます。('🔗リンクのみ'または'📦アーカイブのみ'モードがアクティブな場合は無効)。
\n- ログ表示の切り替え(👁️ / 🙈ボタン):
\nこのボタン(ログ領域の右上)は、メインログビューを変更します:\n- 👁️進捗ログ(デフォルト): すべてのダウンロードアクティビティ、エラー、および概要を表示します。
\n- 🙈見逃したキャラクターログ: 'キャラクターでフィルター'設定のためにスキップされた投稿タイトルのキーワードのリストを表示します。意図せずに見逃している可能性のあるコンテンツを特定するのに役立ちます。
\n- 🔄リセット: すべての入力フィールド、ログをクリアし、一時的な設定をデフォルトにリセットします。ダウンロードがアクティブでない場合にのみ使用できます。
\n- ⬇️ダウンロード開始 / 🔗リンクを抽出 / ⏸️一時停止 / ❌キャンセル: これらのボタンはプロセスを制御します。'キャンセルしてUIをリセット'は、現在の操作を停止し、UIのソフトリセットを実行し、URLとディレクトリの入力を保持します。'一時停止/再開'は、一時的な停止と継続を可能にします。
\n- 一部のファイルが回復可能なエラー('IncompleteRead'など)で失敗した場合、セッションの最後に再試行するように求められることがあります。
\n
\n
これで準備完了です!'完了'をクリックしてツアーを閉じ、ダウンローダーの使用を開始します。",
+ "help_guide_dialog_title": "Kemono Downloader - 機能ガイド",
+ "help_guide_github_tooltip": "プロジェクトのGitHubページにアクセスします(ブラウザで開きます)",
+ "help_guide_instagram_tooltip": "Instagramページにアクセスします(ブラウザで開きます)",
+ "help_guide_discord_tooltip": "Discordコミュニティに参加します(ブラウザで開きます)",
+ "help_guide_step1_title": "①はじめにと主な入力",
+ "help_guide_step1_content": "\nこのガイドでは、Kemono Downloaderの機能、フィールド、およびボタンの概要を説明します。
\n主な入力領域(左上)
\n\n- 🔗クリエイター/投稿Kemono URL:\n
\n- クリエイターのページの完全なWebアドレスを入力します(例: https://kemono.su/patreon/user/12345)または特定の投稿(例: .../post/98765)。
\n- Kemono(kemono.su、kemono.party)およびCoomer(coomer.su、coomer.party)のURLをサポートします。
\n
\n \n- ページ範囲(開始から終了):\n
\n- クリエイターURLの場合: 取得するページの範囲を指定します(例: 2〜5ページ)。すべてのページの場合は空白のままにします。
\n- 単一の投稿URLまたはマンガ/コミックモードがアクティブな場合は無効になります。
\n
\n \n- 📁ダウンロード場所:\n
\n- '参照...'をクリックして、ダウンロードしたすべてのファイルを保存するコンピュータ上のメインフォルダを選択します。
\n- '🔗リンクのみ'モードを使用していない限り、このフィールドは必須です。
\n
\n \n- 🎨クリエイター選択ボタン(URL入力の横):\n
\n- パレットアイコン(🎨)をクリックして、'クリエイター選択'ダイアログを開きます。
\n- このダイアログは、
creators.jsonファイル(アプリディレクトリにある必要があります)からクリエイターを読み込みます。 \n- ダイアログ内:\n
\n- 検索バー: 名前またはサービスでクリエイターリストをフィルタリングするには、入力します。
\n- クリエイターリスト:
creators.jsonからクリエイターを表示します。'お気に入り'に登録したクリエイター(JSONデータ内)が一番上に表示されます。 \n- チェックボックス: 名前の横にあるボックスをチェックして、1人または複数のクリエイターを選択します。
\n- 'スコープ'ボタン(例: 'スコープ: キャラクター'): このボタンは、このポップアップからダウンロードを開始するときのダウンロードの構成を切り替えます:\n
- スコープ: キャラクター: ダウンロードは、メインの'ダウンロード場所'に直接キャラクター名のフォルダに整理されます。同じキャラクターの異なるクリエイターの作品がグループ化されます。
\n- スコープ: クリエイター: ダウンロードは、最初にメインの'ダウンロード場所'にクリエイター名のフォルダを作成します。次に、各クリエイターのフォルダ内にキャラクター名のサブフォルダが作成されます。
\n \n- '選択項目を追加'ボタン: これをクリックすると、チェックされたすべてのクリエイターの名前が取得され、メインの'🔗クリエイター/投稿Kemono URL'入力フィールドにコンマで区切って追加されます。その後、ダイアログが閉じます。
\n
\n \n- この機能は、各URLを手動で入力または貼り付けることなく、複数のクリエイターのURLフィールドをすばやく入力する方法を提供します。
\n
\n \n
",
+ "help_guide_step2_title": "②ダウンロードのフィルタリング",
+ "help_guide_step2_content": "\nダウンロードのフィルタリング(左パネル)
\n\n- 🎯キャラクターでフィルター:\n
\n- 名前をコンマで区切って入力します(例:
Tifa, Aerith)。 \n- 共有フォルダのグループ化されたエイリアス(個別のKnown.txtエントリ):
(Vivi, Ulti, Uta)。\n- \"Vivi\"、\"Ulti\"、または\"Uta\"に一致するコンテンツは、(クリーンアップ後)\"Vivi Ulti Uta\"という名前の共有フォルダに保存されます。
\n- これらの名前が新しい場合は、\"Vivi\"、\"Ulti\"、および\"Uta\"を
Known.txtに個別の個別のエントリとして追加するように求められます。 \n
\n \n- 共有フォルダのグループ化されたエイリアス(単一のKnown.txtエントリ):
(Yuffie, Sonon)~(チルダ~に注意してください)。\n- \"Yuffie\"または\"Sonon\"に一致するコンテンツは、\"Yuffie Sonon\"という名前の共有フォルダに保存されます。
\n- 新しい場合は、(エイリアスYuffie、Sononを持つ)\"Yuffie Sonon\"が、
Known.txtに単一のグループエントリとして追加されることが提案されます。 \n
\n \n- このフィルターは、'名前/タイトルでフォルダを分ける'が有効になっている場合にフォルダの命名に影響します。
\n
\n \n- フィルター: [タイプ]ボタン(キャラクターフィルタースコープ): 'キャラクターでフィルター'の適用方法を切り替えます:\n
\nフィルター: ファイル: 個々のファイル名をチェックします。ファイルが一致した場合、投稿は保持されます。一致したファイルのみがダウンロードされます。フォルダの命名は、一致したファイル名のキャラクターを使用します。 \nフィルター: タイトル: 投稿タイトルをチェックします。一致した投稿のすべてのファイルがダウンロードされます。フォルダの命名は、一致した投稿タイトルのキャラクターを使用します。 \nフィルター: 両方: 最初に投稿タイトルをチェックします。一致した場合、すべてのファイルがダウンロードされます。そうでない場合は、ファイル名をチェックし、一致したファイルのみがダウンロードされます。フォルダの命名は、タイトルの一致を優先し、次にファイルの一致を優先します。 \nフィルター: コメント(ベータ): 最初にファイル名をチェックします。ファイルが一致した場合、投稿内のすべてのファイルがダウンロードされます。ファイルの一致がない場合は、投稿のコメントをチェックします。コメントが一致した場合、すべてのファイルがダウンロードされます。(より多くのAPIリクエストを使用します)。フォルダの命名は、ファイルの一致を優先し、次にコメントの一致を優先します。 \n
\n \n- 🗄️カスタムフォルダ名(単一投稿のみ):\n
\n- 特定の投稿URLをダウンロードしていて、'名前/タイトルでフォルダを分ける'が有効になっている場合にのみ表示および使用できます。
\n- その単一投稿のダウンロードフォルダにカスタム名を指定できます。
\n
\n \n- 🚫単語でスキップ:\n
- 特定のコンテンツを無視するには、単語をコンマで区切って入力します(例:
WIP, sketch, preview)。
\n \n- スコープ: [タイプ]ボタン(スキップワードスコープ): '単語でスキップ'の適用方法を切り替えます:\n
\nスコープ: ファイル: これらの単語のいずれかを含む場合、個々のファイルをスキップします。 \nスコープ: 投稿: これらの単語のいずれかを含む場合、投稿全体をスキップします。 \nスコープ: 両方: 両方を適用します(最初に投稿タイトル、次に個々のファイル)。 \n
\n \n- ✂️名前から単語を削除:\n
- ダウンロードされたファイル名から削除する単語をコンマで区切って入力します(例:
patreon, [HD])(大文字と小文字を区別しません)。
\n \n- ファイルをフィルター(ラジオボタン): ダウンロードするものを選択します:\n
\nすべて: 見つかったすべての種類のファイルをダウンロードします。 \n画像/GIF: 一般的な画像形式(JPG、PNG、GIF、WEBPなど)とGIFのみ。 \n動画: 一般的な動画形式(MP4、MKV、WEBM、MOVなど)のみ。 \n📦アーカイブのみ: .zipファイルと.rarファイルのみを排他的にダウンロードします。これを選択すると、'zipをスキップ'および'rarをスキップ'チェックボックスが自動的に無効になり、オフになります。'外部リンクを表示'も無効になります。 \n🎧音声のみ: 一般的な音声形式(MP3、WAV、FLAC、M4A、OGGなど)のみをダウンロードします。他のファイル固有のオプションは、'画像'または'動画'モードと同様に動作します。 \n🔗リンクのみ: ファイルをダウンロードする代わりに、投稿の説明から外部リンクを抽出して表示します。ダウンロード関連のオプションと'外部リンクを表示'は無効になります。メインのダウンロードボタンは'🔗リンクを抽出'になります。 \n
\n \n
",
+ "help_guide_step3_title": "③ダウンロードオプションと設定",
+ "help_guide_step3_content": "\nダウンロードオプションと設定(左パネル)
\n\n- .zipをスキップ/.rarをスキップ: これらのアーカイブファイルの種類をダウンロードしないようにするには、チェックボックスをオンにします。('📦アーカイブのみ'フィルターモードが選択されている場合は無効になり、無視されます)。
\n- サムネイルのみをダウンロード: フルサイズのファイルではなく、小さなプレビュー画像をダウンロードします(利用可能な場合)。
\n- 大きな画像を圧縮(WebPへ): 'Pillow'(PIL)ライブラリがインストールされている場合、1.5MBを超える画像は、WebPバージョンが大幅に小さい場合にWebP形式に変換されます。
\n- ⚙️詳細設定:\n
\n- 名前/タイトルでフォルダを分ける: 'キャラクターでフィルター'入力または投稿タイトルに基づいてサブフォルダを作成します。フォルダ名のフォールバックとしてKnown.txtリストを使用できます。
",
+ "help_guide_step4_title": "④詳細設定(パート1)",
+ "help_guide_step4_content": "⚙️詳細設定(続き)
\n- 投稿ごとにサブフォルダを作成: 'フォルダを分ける'がオンの場合、これにより、メインのキャラクター/タイトルフォルダ内に各個別の投稿の追加のサブフォルダが作成されます。
\n- Cookieを使用: リクエストにCookieを使用するには、このボックスをチェックします。\n
\n- テキストフィールド: Cookie文字列を直接入力します(例:
name1=value1; name2=value2)。 \n- 参照...:
cookies.txtファイル(Netscape形式)を選択します。パスがテキストフィールドに表示されます。 \n- 優先度: テキストフィールド(入力されている場合)が参照ファイルよりも優先されます。'Cookieを使用'がチェックされているが、両方が空の場合、アプリのディレクトリから
cookies.txtを読み込もうとします。 \n
\n \n- マルチスレッドを使用とスレッド入力:\n
\n- より高速な操作を有効にします。'スレッド'入力の数値の意味:\n
\n- クリエイターフィードの場合: 同時に処理する投稿の数。各投稿のファイルは、そのワーカーによって順次ダウンロードされます('日付ベース'のマンガの命名がオンで、1つの投稿ワーカーを強制する場合を除く)。
\n- 単一投稿URLの場合: その単一投稿から同時にダウンロードするファイルの数。
\n
\n \n- オフの場合、1つのスレッドが使用されます。スレッド数が多い場合(例: >40)、警告が表示されることがあります。
\n
\n
",
+ "help_guide_step5_title": "⑤詳細設定(パート2)とアクション",
+ "help_guide_step5_content": "⚙️詳細設定(続き)
\n- ログに外部リンクを表示: チェックすると、メインログの下にセカンダリログパネルが表示され、投稿の説明で見つかった外部リンクが表示されます。('🔗リンクのみ'または'📦アーカイブのみ'モードがアクティブな場合は無効)。
\n- 📖マンガ/コミックモード(クリエイターURLのみ): 順次コンテンツ用に設計されています。\n
\n- 投稿を古いものから新しいものへとダウンロードします。
\n- すべての投稿がフェッチされるため、'ページ範囲'入力は無効になります。
\n- このモードがクリエイターフィードでアクティブな場合、ログ領域の右上にファイル名スタイルの切り替えボタン(例: '名前: 投稿タイトル')が表示されます。これをクリックして、命名スタイルを切り替えます:\n
\n名前: 投稿タイトル(デフォルト): 投稿の最初のファイルは、クリーンアップされた投稿タイトルにちなんで名前が付けられます(例: 'My Chapter 1.jpg')。*同じ投稿*の後続のファイルは、元のファイル名を保持しようとします(例: 'page_02.png'、'bonus_art.jpg')。投稿にファイルが1つしかない場合は、投稿タイトルにちなんで名前が付けられます。これは、ほとんどのマンガ/コミックに一般的に推奨されます。 \n名前: オリジナルファイル: すべてのファイルは、元のファイル名を保持しようとします。 \n名前: オリジナルファイル: すべてのファイルは、元のファイル名を保持しようとします。このスタイルがアクティブな場合、オプションのファイル名プレフィックス(例: 'MySeries_')の入力フィールドがこのスタイルボタンの横に表示されます。例: 'MySeries_OriginalFile.jpg'。 \n名前: タイトル+G.Num(投稿タイトル+グローバル番号付け): 現在のダウンロードセッションのすべての投稿のすべてのファイルは、クリーンアップされた投稿タイトルをプレフィックスとして使用し、その後にグローバルカウンターを付けて順次名前が付けられます。例: 投稿 'Chapter 1' (2ファイル) -> 'Chapter 1 001.jpg'、'Chapter 1 002.png'。次の投稿 'Chapter 2' (1ファイル) -> 'Chapter 2 003.jpg'。このスタイルの場合、投稿処理のマルチスレッドは自動的に無効になります。 \n名前: 日付ベース: ファイルは、公開順に基づいて順次名前が付けられます(001.ext、002.ext、...)。このスタイルがアクティブな場合、オプションのファイル名プレフィックス(例: 'MySeries_')の入力フィールドがこのスタイルボタンの横に表示されます。例: 'MySeries_001.jpg'。このスタイルの場合、投稿処理のマルチスレッドは自動的に無効になります。 \n
\n \n- '名前: 投稿タイトル'、'名前: タイトル+G.Num'、または'名前: 日付ベース'のスタイルで最良の結果を得るには、フォルダ構成にマンガ/シリーズのタイトルを付けて'キャラクターでフィルター'フィールドを使用します。
\n
\n \n
\n主なアクション(左パネル)
\n\n- ⬇️ダウンロード開始 / 🔗リンクを抽出: このボタンのテキストと機能は、'ファイルをフィルター'ラジオボタンの選択に基づいて変更されます。メイン操作を開始します。
\n- ⏸️ダウンロードを一時停止 / ▶️ダウンロードを再開: 現在のダウンロード/抽出プロセスを一時的に停止し、後で再開できます。一時停止中に一部のUI設定を変更できます。
\n- ❌キャンセルしてUIをリセット: 現在の操作を停止し、UIのソフトリセットを実行します。URLとダウンロードディレクトリの入力は保持されますが、他の設定とログはクリアされます。
\n
",
+ "help_guide_step6_title": "⑥既知のシリーズ/キャラクターリスト",
+ "help_guide_step6_content": "\n既知のシリーズ/キャラクターリストの管理(左下)
\nこのセクションは、'名前/タイトルでフォルダを分ける'がオンの場合のスマートなフォルダ構成に役立つKnown.txtファイルを管理するのに役立ちます。特に、投稿がアクティブな'キャラクターでフィルター'入力に一致しない場合のフォールバックとして役立ちます。
\n\n- Known.txtを開く: デフォルトのテキストエディタで
Known.txtファイル(アプリディレクトリにあります)を開き、高度な編集(複雑なグループ化されたエイリアスの作成など)を行います。 \n- キャラクターを検索...: 以下に表示される既知の名前のリストをフィルタリングします。
\n- リストウィジェット:
Known.txtからマスター名を表示します。ここでエントリを選択して削除します。 \n- 新しいシリーズ/キャラクター名を追加(入力フィールド): 追加する名前またはグループを入力します。\n
\n- 単純な名前: 例:
My Awesome Series。単一のエントリとして追加します。 \n- 個別のKnown.txtエントリのグループ: 例:
(Vivi, Ulti, Uta)。\"Vivi\"、\"Ulti\"、および\"Uta\"をKnown.txtに3つの個別の個別のエントリとして追加します。 \n- 共有フォルダと単一のKnown.txtエントリのグループ(チルダ
~): 例: (Character A, Char A)~。Known.txtに\"Character A Char A\"という名前のエントリを追加します。\"Character A\"と\"Char A\"は、この単一のフォルダ/エントリのエイリアスになります。 \n
\n \n- ボタン➕追加: 上の入力フィールドの名前/グループをリストと
Known.txtに追加します。 \n- ボタン⤵️フィルターに追加:\n
\n- '既知のシリーズ/キャラクター'リストの'➕追加'ボタンの横にあります。
\n- これをクリックすると、
Known.txtファイルのすべての名前がチェックボックス付きで表示されるポップアップウィンドウが開きます。 \n- ポップアップには、名前のリストをすばやくフィルタリングするための検索バーが含まれています。
\n- チェックボックスを使用して、1つまたは複数の名前を選択できます。
\n- '選択項目を追加'をクリックして、選択した名前をメインウィンドウの'キャラクターでフィルター'入力フィールドに挿入します。
\nKnown.txtで選択した名前が元々グループであった場合(例: Known.txtで(Boa, Hancock)と定義されている)、フィルターフィールドに(Boa, Hancock)~として追加されます。単純な名前はそのまま追加されます。 \n- 'すべて選択'ボタンと'すべて選択解除'ボタンは、便宜上ポップアップで利用できます。
\n- 'キャンセル'をクリックして、変更せずにポップアップを閉じます。
\n
\n \n- ボタン🗑️選択項目を削除: 選択した名前をリストと
Known.txtから削除します。 \n- ボタン❓(これです!): この包括的なヘルプガイドを表示します。
\n
",
+ "help_guide_step7_title": "⑦ログ領域と制御",
+ "help_guide_step7_content": "\nログ領域と制御(右パネル)
\n\n- 📜進捗ログ/抽出されたリンクのログ(ラベル): メインログ領域のタイトル。'🔗リンクのみ'モードがアクティブな場合に変化します。
\n- リンクを検索... / ボタン🔍(リンク検索):\n
- '🔗リンクのみ'モードがアクティブな場合にのみ表示されます。メインログに表示される抽出されたリンクをテキスト、URL、またはプラットフォームでリアルタイムにフィルタリングできます。
\n \n- 名前: [スタイル]ボタン(マンガのファイル名スタイル):\n
- マンガ/コミックモードがクリエイターフィードでアクティブで、'リンクのみ'または'アーカイブのみ'モードでない場合にのみ表示されます。
\n- ファイル名スタイルを切り替えます:
投稿タイトル、オリジナルファイル、日付ベース。(詳細については、マンガ/コミックモードのセクションを参照してください)。 \n- 'オリジナルファイル'または'日付ベース'スタイルがアクティブな場合、オプションのファイル名プレフィックスの入力フィールドがこのボタンの横に表示されます。
\n
\n \n- マルチパート: [オン/オフ]ボタン:\n
- 個々の大きなファイルのマルチセグメントダウンロードを切り替えます。\n
- オン: 大きなファイルのダウンロードを高速化する場合があります(例: 動画)が、UIの途切れや、多くの小さなファイルでログがスパムになる可能性があります。有効にすると警告が表示されます。マルチパートダウンロードが失敗した場合、単一ストリームとして再試行します。
\n- オフ(デフォルト): ファイルは単一のストリームでダウンロードされます。
\n
\n - '🔗リンクのみ'または'📦アーカイブのみ'モードがアクティブな場合は無効になります。
\n
\n \n- ボタン👁️ / 🙈(ログ表示の切り替え): メインログビューを変更します:\n
\n- 👁️進捗ログ(デフォルト): すべてのダウンロードアクティビティ、エラー、および概要を表示します。
\n- 🙈見逃したキャラクターログ: 'キャラクターでフィルター'設定のためにスキップされた投稿タイトル/コンテンツのキーワードのリストを表示します。意図せずに見逃している可能性のあるコンテンツを特定するのに役立ちます。
\n
\n \n- ボタン🔄リセット: すべての入力フィールド、ログをクリアし、一時的な設定をデフォルトにリセットします。ダウンロードがアクティブでない場合にのみ使用できます。
\n- メインログ出力(テキスト領域): 詳細な進捗メッセージ、エラー、および概要を表示します。'🔗リンクのみ'モードがアクティブな場合、この領域には抽出されたリンクが表示されます。
\n- 見逃したキャラクターログ出力(テキスト領域): (👁️ / 🙈切り替えで表示)キャラクターフィルターのためにスキップされた投稿/ファイルを表示します。
\n- 外部リンク出力(テキスト領域): 'ログに外部リンクを表示'がチェックされている場合、メインログの下に表示されます。投稿の説明で見つかった外部リンクを表示します。
\n- リンクをエクスポートボタン:\n
- '🔗リンクのみ'モードがアクティブで、リンクが抽出されている場合にのみ表示および有効になります。
\n- 抽出されたすべてのリンクを
.txtファイルに保存できます。 \n
\n \n- 進捗ラベル: [ステータス]: ダウンロードまたはリンク抽出プロセスの全体的な進捗を表示します(例: 処理済みの投稿)。
\n- ファイル進捗ラベル: 速度やサイズ、またはマルチパートダウンロードステータスなど、個々のファイルのダウンロードの進捗を表示します。
\n
",
+ "help_guide_step8_title": "⑧お気に入りモードと今後の機能",
+ "help_guide_step8_content": "\nお気に入りモード(Kemono.suのお気に入りからダウンロード)
\nこのモードでは、Kemono.suでお気に入りに登録したアーティストから直接コンテンツをダウンロードできます。
\n\n- ⭐有効にする方法:\n
\n- '🔗リンクのみ'ラジオボタンの横にある'⭐お気に入りモード'チェックボックスをオンにします。
\n
\n \n- お気に入りモードのUIの変更:\n
\n- '🔗クリエイター/投稿Kemono URL'入力領域が、お気に入りモードがアクティブであることを示すメッセージに置き換えられます。
\n- 標準の'ダウンロード開始'、'一時停止'、'キャンセル'ボタンが、次のように置き換えられます:\n
\n- '🖼️お気に入りのアーティスト'ボタン
\n- '📄お気に入りの投稿'ボタン
\n
\n \n- お気に入りを取得するにはCookieが必要なため、'🍪Cookieを使用'オプションが自動的に有効になり、ロックされます。
\n
\n \n- ボタン🖼️お気に入りのアーティスト:\n
\n- これをクリックすると、Kemono.suでお気に入りに登録したすべてのアーティストを一覧表示するダイアログが開きます。
\n- このリストから1人または複数のアーティストを選択して、コンテンツをダウンロードできます。
\n
\n \n- ボタン📄お気に入りの投稿(今後の機能):\n
\n- 特定のお気に入りの投稿を(特にシリーズの一部である場合は、順次、マンガのような順序で)ダウンロードすることは、現在開発中の機能です。
\n- 特にマンガのような順次閲覧の場合、お気に入りの投稿を処理する最良の方法はまだ検討中です。
\n- お気に入りの投稿をダウンロードして整理する方法(例: お気に入りからの\"マンガスタイル\")について具体的なアイデアやユースケースがある場合は、プロジェクトのGitHubページで問題を開くか、ディスカッションに参加することを検討してください。皆様のご意見をお待ちしております!
\n
\n \n- お気に入りのダウンロードスコープ(ボタン):\n
\n- このボタン('お気に入りの投稿'の横)は、選択したお気に入りのアーティストのコンテンツをどこにダウンロードするかを制御します:\n
\n- スコープ: 選択した場所: 選択したすべてのアーティストが、UIで設定したメインの'ダウンロード場所'にダウンロードされます。フィルターはすべてのコンテンツにグローバルに適用されます。
\n- スコープ: アーティストフォルダ: 選択したアーティストごとに、メインの'ダウンロード場所'内にサブフォルダ(アーティスト名で命名)が自動的に作成されます。そのアーティストのコンテンツは、特定のフォルダに保存されます。フィルターは、各アーティストの専用フォルダ内に適用されます。
\n
\n \n
\n \n- お気に入りモードのフィルター:\n
\n- UIで設定した'🎯キャラクターでフィルター'、'🚫単語でスキップ'、および'ファイルをフィルター'オプションは、選択したお気に入りのアーティストからダウンロードしたコンテンツに引き続き適用されます。
\n
\n \n
",
+ "help_guide_step9_title": "⑨キーファイルとツアー",
+ "help_guide_step9_content": "\nアプリケーションで使用されるキーファイル
\n\nKnown.txt:\n\n- アプリケーションディレクトリ(
.exeまたはmain.pyがある場所)にあります。 \n- '名前/タイトルでフォルダを分ける'が有効になっている場合の自動フォルダ構成のために、既知のシリーズ、キャラクター、またはシリーズタイトルのリストを保存します。
\n- 形式:\n
\n- 各行は1つのエントリです。
\n- 単純な名前: 例:
My Awesome Series。一致するコンテンツは、\"My Awesome Series\"という名前のフォルダに保存されます。 \n- グループ化されたエイリアス: 例:
(Character A, Char A, Alt Name A)。\"Character A\"、\"Char A\"、または\"Alt Name A\"に一致するコンテンツはすべて、(クリーンアップ後)\"Character A Char A Alt Name A\"という名前の単一のフォルダに保存されます。括弧内のすべての用語は、そのフォルダのエイリアスになります。 \n
\n \n- 使用法: 投稿がアクティブな'キャラクターでフィルター'入力に一致しない場合のフォルダ命名のフォールバックとして機能します。UIを介して単純なエントリを管理したり、複雑なエイリアスのファイルを直接編集したりできます。アプリは起動時または次回の使用時に再読み込みします。
\n
\n \ncookies.txt(オプション):\n\n- 'Cookieを使用'機能を使用していて、直接Cookie文字列を提供しないか、特定のファイルを参照しない場合、アプリはそのディレクトリで
cookies.txtという名前のファイルを探します。 \n- 形式: Netscape Cookieファイル形式である必要があります。
\n- 使用法: ダウンローダーがブラウザのログインセッションを使用して、Kemono/Coomerでログインの背後にある可能性のあるコンテンツにアクセスできるようにします。
\n
\n \n
\n初回ユーザー向けツアー
\n\n- 最初の起動時(またはリセットされた場合)、主な機能を案内するウェルカムツアーダイアログが表示されます。スキップするか、\"このツアーを二度と表示しない\"を選択できます。
\n
\n多くのUI要素には、マウスを合わせると表示されるツールチップもあり、簡単なヒントを提供します。
\n"
}
def get_translation (language_code ,key ,default_text =""):
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/services/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/services/drive_downloader.py b/src/services/drive_downloader.py
new file mode 100644
index 0000000..a4f8bdb
--- /dev/null
+++ b/src/services/drive_downloader.py
@@ -0,0 +1,163 @@
+# --- Standard Library Imports ---
+import os
+import re
+import traceback
+import json
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
+
+# --- Third-Party Library Imports ---
+import requests
+try:
+ from mega import Mega
+ MEGA_AVAILABLE = True
+except ImportError:
+ MEGA_AVAILABLE = False
+
+try:
+ import gdown
+ GDOWN_AVAILABLE = True
+except ImportError:
+ GDOWN_AVAILABLE = False
+
+# --- Helper Functions ---
+
+def _get_filename_from_headers(headers):
+ """
+ Extracts a filename from the Content-Disposition header.
+
+ Args:
+ headers (dict): A dictionary of HTTP response headers.
+
+ Returns:
+ str or None: The extracted filename, or None if not found.
+ """
+ cd = headers.get('content-disposition')
+ if not cd:
+ return None
+
+ fname_match = re.findall('filename="?([^"]+)"?', cd)
+ if fname_match:
+ # Sanitize the filename to prevent directory traversal issues
+ # and remove invalid characters for most filesystems.
+ sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip())
+ return sanitized_name
+
+ return None
+
+# --- Main Service Downloader Functions ---
+
+def download_mega_file(mega_link, 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.
+ """
+ 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(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)
+
+ 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}")
+
+ 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
+
+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}")
+ 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}")
+ else:
+ raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}")
+
+ except Exception as e:
+ logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}")
+ traceback.print_exc(limit=2)
+ raise
+
+def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
+ """
+ Downloads a file from a public Dropbox link by modifying the URL for direct download.
+
+ Args:
+ dropbox_link (str): The public Dropbox link to the file.
+ download_path (str): The directory to save the downloaded file.
+ logger_func (callable): Function to use for logging.
+ """
+ logger_func(f" [Dropbox] Attempting to download: {dropbox_link}")
+
+ # Modify the Dropbox URL to force a direct download instead of showing the preview page.
+ parsed_url = urlparse(dropbox_link)
+ query_params = parse_qs(parsed_url.query)
+ query_params['dl'] = ['1']
+ new_query = urlencode(query_params, doseq=True)
+ direct_download_url = urlunparse(parsed_url._replace(query=new_query))
+
+ logger_func(f" [Dropbox] Using direct download URL: {direct_download_url}")
+
+ try:
+ if not os.path.exists(download_path):
+ os.makedirs(download_path, exist_ok=True)
+ logger_func(f" [Dropbox] Created download directory: {download_path}")
+
+ with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r:
+ r.raise_for_status()
+
+ # Determine filename from headers or URL
+ filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_file"
+ full_save_path = os.path.join(download_path, filename)
+
+ logger_func(f" [Dropbox] Starting download of '{filename}'...")
+
+ # Write file to disk in chunks
+ with open(full_save_path, 'wb') as f:
+ for chunk in r.iter_content(chunk_size=8192):
+ f.write(chunk)
+
+ logger_func(f" [Dropbox] ✅ Dropbox file downloaded successfully: {full_save_path}")
+
+ except Exception as e:
+ logger_func(f" [Dropbox] ❌ An error occurred during Dropbox download: {e}")
+ traceback.print_exc(limit=2)
+ raise
diff --git a/multipart_downloader.py b/src/services/multipart_downloader.py
similarity index 71%
rename from multipart_downloader.py
rename to src/services/multipart_downloader.py
index 7e207c5..b7db348 100644
--- a/multipart_downloader.py
+++ b/src/services/multipart_downloader.py
@@ -1,6 +1,6 @@
+# --- Standard Library Imports ---
import os
import time
-import requests
import hashlib
import http.client
import traceback
@@ -8,21 +8,38 @@ import threading
import queue
from concurrent.futures import ThreadPoolExecutor, as_completed
+# --- Third-Party Library Imports ---
+import requests
+
+# --- Module Constants ---
CHUNK_DOWNLOAD_RETRY_DELAY = 2
MAX_CHUNK_DOWNLOAD_RETRIES = 1
-DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256
+DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk
+
+# Flag to indicate if this module and its dependencies are available.
+# This was missing and caused the ImportError.
+MULTIPART_DOWNLOADER_AVAILABLE = True
-def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, headers,
- part_num, total_parts, progress_data, cancellation_event, skip_event, pause_event, global_emit_time_ref, cookies_for_chunk,
- logger_func, emitter=None, api_original_filename=None):
+def _download_individual_chunk(
+ chunk_url, temp_file_path, start_byte, end_byte, headers,
+ part_num, total_parts, progress_data, cancellation_event,
+ skip_event, pause_event, global_emit_time_ref, cookies_for_chunk,
+ logger_func, emitter=None, api_original_filename=None
+):
+ """
+ Downloads a single segment (chunk) of a larger file. This function is
+ intended to be run in a separate thread by a ThreadPoolExecutor.
+
+ It handles retries, pauses, and cancellations for its specific chunk.
+ """
+ # --- Pre-download checks for control events ---
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download cancelled before start.")
return 0, False
if skip_event and skip_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event triggered before start.")
return 0, False
-
if pause_event and pause_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download paused before start...")
while pause_event.is_set():
@@ -32,83 +49,66 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte,
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
+ # Prepare headers for the specific byte range of this chunk
chunk_headers = headers.copy()
- if end_byte != -1 :
+ if end_byte != -1:
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
- elif start_byte == 0 and end_byte == -1:
- pass
-
+
bytes_this_chunk = 0
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = 0
+ # --- Retry Loop ---
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
if cancellation_event and cancellation_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during retry loop.")
return bytes_this_chunk, False
- if skip_event and skip_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during retry loop.")
- return bytes_this_chunk, False
- if pause_event and pause_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused during retry loop...")
- while pause_event.is_set():
- if cancellation_event and cancellation_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled while paused in retry loop.")
- return bytes_this_chunk, False
- time.sleep(0.2)
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed from retry loop pause.")
try:
if attempt > 0:
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying download (Attempt {attempt}/{MAX_CHUNK_DOWNLOAD_RETRIES})...")
+ logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...")
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = bytes_this_chunk
- log_msg = f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}"
- logger_func(log_msg)
+
+ logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}")
+
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
response.raise_for_status()
- if start_byte == 0 and end_byte == -1 and int(response.headers.get('Content-Length', 0)) == 0:
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Confirmed 0-byte file.")
- with progress_data['lock']:
- progress_data['chunks_status'][part_num]['active'] = False
- progress_data['chunks_status'][part_num]['speed_bps'] = 0
- return 0, True
+ # --- Data Writing Loop ---
with open(temp_file_path, 'r+b') as f:
f.seek(start_byte)
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during data iteration.")
- return bytes_this_chunk, False
- if skip_event and skip_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during data iteration.")
return bytes_this_chunk, False
if pause_event and pause_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused during data iteration...")
+ # Handle pausing during the download stream
+ logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...")
while pause_event.is_set():
- if cancellation_event and cancellation_event.is_set():
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled while paused in data iteration.")
- return bytes_this_chunk, False
+ if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False
time.sleep(0.2)
- logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed from data iteration pause.")
+ logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.")
+
if data_segment:
f.write(data_segment)
bytes_this_chunk += len(data_segment)
+ # Update shared progress data structure
with progress_data['lock']:
progress_data['total_downloaded_so_far'] += len(data_segment)
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
- progress_data['chunks_status'][part_num]['active'] = True
-
+
+ # Calculate and update speed for this chunk
current_time = time.time()
- time_delta_speed = current_time - last_speed_calc_time
- if time_delta_speed > 0.5:
+ time_delta = current_time - last_speed_calc_time
+ if time_delta > 0.5:
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
- current_speed_bps = (bytes_delta * 8) / time_delta_speed if time_delta_speed > 0 else 0
+ current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
last_speed_calc_time = current_time
- bytes_at_last_speed_calc = bytes_this_chunk
+ bytes_at_last_speed_calc = bytes_this_chunk
+
+ # Emit progress signal to the UI via the queue
if emitter and (current_time - global_emit_time_ref[0] > 0.25):
global_emit_time_ref[0] = current_time
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
@@ -116,28 +116,19 @@ def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte,
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter, 'file_progress_signal'):
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
+
+ # If we reach here, the download for this chunk was successful
return bytes_this_chunk, True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
- if isinstance(e, requests.exceptions.ConnectionError) and \
- ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
- logger_func(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
- if attempt == MAX_CHUNK_DOWNLOAD_RETRIES:
- logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Failed after {MAX_CHUNK_DOWNLOAD_RETRIES} retries.")
- return bytes_this_chunk, False
except requests.exceptions.RequestException as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
- if ("Failed to resolve" in str(e) or "NameResolutionError" in str(e)):
- logger_func(" 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN.")
- return bytes_this_chunk, False
+ return bytes_this_chunk, False # Break loop on non-retryable errors
except Exception as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
-
return bytes_this_chunk, False
- with progress_data['lock']:
- progress_data['chunks_status'][part_num]['active'] = False
- progress_data['chunks_status'][part_num]['speed_bps'] = 0
+
return bytes_this_chunk, False
diff --git a/src/ui/__init__.py b/src/ui/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/ui/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/ui/assets.py b/src/ui/assets.py
new file mode 100644
index 0000000..628c718
--- /dev/null
+++ b/src/ui/assets.py
@@ -0,0 +1,42 @@
+# --- Standard Library Imports ---
+import os
+import sys
+
+# --- PyQt5 Imports ---
+from PyQt5.QtGui import QIcon
+
+# --- Asset Management ---
+
+# This global variable will cache the icon so we don't have to load it from disk every time.
+_app_icon_cache = None
+
+def get_app_icon_object():
+ """
+ Loads and caches the application icon from the assets folder.
+ This function is now centralized to prevent circular imports.
+
+ Returns:
+ QIcon: The application icon object.
+ """
+ global _app_icon_cache
+ if _app_icon_cache and not _app_icon_cache.isNull():
+ return _app_icon_cache
+
+ # Determine the project's base directory, whether running from source or as a bundled app
+ if getattr(sys, 'frozen', False):
+ # The application is frozen (e.g., with PyInstaller)
+ base_dir = os.path.dirname(sys.executable)
+ else:
+ # The application is running from a .py file
+ # This path navigates up from src/ui/ to the project root
+ app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+
+ icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
+
+ if os.path.exists(icon_path):
+ _app_icon_cache = QIcon(icon_path)
+ else:
+ print(f"Warning: Application icon not found at {icon_path}")
+ _app_icon_cache = QIcon() # Return an empty icon as a fallback
+
+ return _app_icon_cache
diff --git a/src/ui/dialogs/ConfirmAddAllDialog.py b/src/ui/dialogs/ConfirmAddAllDialog.py
new file mode 100644
index 0000000..be91480
--- /dev/null
+++ b/src/ui/dialogs/ConfirmAddAllDialog.py
@@ -0,0 +1,177 @@
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
+ QPushButton, QVBoxLayout
+)
+
+# --- Local Application Imports ---
+# This assumes the new project structure is in place.
+from ...i18n.translator import get_translation
+# get_app_icon_object is defined in the main window module in this refactoring plan.
+from ..main_window import get_app_icon_object
+
+# --- Constants for Dialog Choices ---
+# These were moved from main.py to be self-contained within this module's context.
+CONFIRM_ADD_ALL_ACCEPTED = 1
+CONFIRM_ADD_ALL_SKIP_ADDING = 2
+CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
+
+
+class ConfirmAddAllDialog(QDialog):
+ """
+ A dialog to confirm adding multiple new character/series names to Known.txt.
+ It appears when the user provides filter names that are not already known,
+ allowing them to persist these names for future use.
+ """
+
+ def __init__(self, new_filter_objects_list, parent_app, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ new_filter_objects_list (list): A list of filter objects (dicts) to propose adding.
+ parent_app (DownloaderApp): A reference to the main application window for theming and translations.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app
+ self.setModal(True)
+ self.new_filter_objects_list = new_filter_objects_list
+ # Default choice if the dialog is closed without a button press
+ self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically
+ screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
+ scale_factor = screen_height / 768.0
+ base_min_w, base_min_h = 480, 350
+ 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)
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ main_layout = QVBoxLayout(self)
+
+ self.info_label = QLabel()
+ self.info_label.setWordWrap(True)
+ main_layout.addWidget(self.info_label)
+
+ self.names_list_widget = QListWidget()
+ self._populate_list()
+ main_layout.addWidget(self.names_list_widget)
+
+ # --- Selection Buttons ---
+ selection_buttons_layout = QHBoxLayout()
+ self.select_all_button = QPushButton()
+ self.select_all_button.clicked.connect(self._select_all_items)
+ selection_buttons_layout.addWidget(self.select_all_button)
+
+ self.deselect_all_button = QPushButton()
+ self.deselect_all_button.clicked.connect(self._deselect_all_items)
+ selection_buttons_layout.addWidget(self.deselect_all_button)
+ selection_buttons_layout.addStretch()
+ main_layout.addLayout(selection_buttons_layout)
+
+ # --- Action Buttons ---
+ buttons_layout = QHBoxLayout()
+ self.add_selected_button = QPushButton()
+ self.add_selected_button.clicked.connect(self._accept_add_selected)
+ self.add_selected_button.setDefault(True)
+ buttons_layout.addWidget(self.add_selected_button)
+
+ self.skip_adding_button = QPushButton()
+ self.skip_adding_button.clicked.connect(self._reject_skip_adding)
+ buttons_layout.addWidget(self.skip_adding_button)
+ buttons_layout.addStretch()
+
+ self.cancel_download_button = QPushButton()
+ self.cancel_download_button.clicked.connect(self._reject_cancel_download)
+ buttons_layout.addWidget(self.cancel_download_button)
+
+ main_layout.addLayout(buttons_layout)
+
+ def _populate_list(self):
+ """Populates the list widget with the new names to be confirmed."""
+ for filter_obj in self.new_filter_objects_list:
+ item_text = filter_obj["name"]
+ list_item = QListWidgetItem(item_text)
+ list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
+ list_item.setCheckState(Qt.Checked)
+ list_item.setData(Qt.UserRole, filter_obj)
+ self.names_list_widget.addItem(list_item)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("confirm_add_all_dialog_title", "Confirm Adding New Names"))
+ self.info_label.setText(self._tr("confirm_add_all_info_label", "The following new names/groups..."))
+ self.select_all_button.setText(self._tr("confirm_add_all_select_all_button", "Select All"))
+ self.deselect_all_button.setText(self._tr("confirm_add_all_deselect_all_button", "Deselect All"))
+ self.add_selected_button.setText(self._tr("confirm_add_all_add_selected_button", "Add Selected to Known.txt"))
+ self.skip_adding_button.setText(self._tr("confirm_add_all_skip_adding_button", "Skip Adding These"))
+ self.cancel_download_button.setText(self._tr("confirm_add_all_cancel_download_button", "Cancel Download"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ def _select_all_items(self):
+ """Checks all items in the list."""
+ for i in range(self.names_list_widget.count()):
+ self.names_list_widget.item(i).setCheckState(Qt.Checked)
+
+ def _deselect_all_items(self):
+ """Unchecks all items in the list."""
+ for i in range(self.names_list_widget.count()):
+ self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
+
+ def _accept_add_selected(self):
+ """Sets the user choice to the list of selected items and accepts the dialog."""
+ selected_objects = []
+ for i in range(self.names_list_widget.count()):
+ item = self.names_list_widget.item(i)
+ if item.checkState() == Qt.Checked:
+ filter_obj = item.data(Qt.UserRole)
+ if filter_obj:
+ selected_objects.append(filter_obj)
+
+ self.user_choice = selected_objects
+ self.accept()
+
+ def _reject_skip_adding(self):
+ """Sets the user choice to skip adding and rejects the dialog."""
+ self.user_choice = CONFIRM_ADD_ALL_SKIP_ADDING
+ self.reject()
+
+ def _reject_cancel_download(self):
+ """Sets the user choice to cancel the entire download and rejects the dialog."""
+ self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
+ self.reject()
+
+ def exec_(self):
+ """
+ Overrides the default exec_ to handle the return value logic, ensuring a
+ sensible default if no items are selected but the "Add" button is clicked.
+ """
+ super().exec_()
+ # If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
+ if isinstance(self.user_choice, list) and not self.user_choice:
+ return CONFIRM_ADD_ALL_SKIP_ADDING
+ return self.user_choice
diff --git a/src/ui/dialogs/CookieHelpDialog.py b/src/ui/dialogs/CookieHelpDialog.py
new file mode 100644
index 0000000..248cf29
--- /dev/null
+++ b/src/ui/dialogs/CookieHelpDialog.py
@@ -0,0 +1,135 @@
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+
+
+class CookieHelpDialog(QDialog):
+ """
+ A dialog to explain how to get a cookies.txt file.
+ It can be displayed as a simple informational popup or as a modal choice
+ when cookies are required but not found.
+ """
+ # Constants to define the user's choice from the dialog
+ CHOICE_PROCEED_WITHOUT_COOKIES = 1
+ CHOICE_CANCEL_DOWNLOAD = 2
+ CHOICE_OK_INFO_ONLY = 3
+
+ def __init__(self, parent_app, parent=None, offer_download_without_option=False):
+ """
+ Initializes the dialog.
+
+ Args:
+ parent_app (DownloaderApp): A reference to the main application window.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ offer_download_without_option (bool): If True, shows buttons to
+ "Download without Cookies" and "Cancel Download". If False,
+ shows only an "OK" button for informational purposes.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app
+ self.setModal(True)
+ self.offer_download_without_option = offer_download_without_option
+ self.user_choice = None
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ self.setMinimumWidth(500)
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ main_layout = QVBoxLayout(self)
+
+ self.info_label = QLabel()
+ self.info_label.setTextFormat(Qt.RichText)
+ self.info_label.setOpenExternalLinks(True)
+ self.info_label.setWordWrap(True)
+ main_layout.addWidget(self.info_label)
+
+ button_layout = QHBoxLayout()
+ button_layout.addStretch(1)
+
+ if self.offer_download_without_option:
+ # Add buttons for making a choice
+ self.download_without_button = QPushButton()
+ self.download_without_button.clicked.connect(self._proceed_without_cookies)
+ button_layout.addWidget(self.download_without_button)
+
+ self.cancel_button = QPushButton()
+ self.cancel_button.clicked.connect(self._cancel_download)
+ button_layout.addWidget(self.cancel_button)
+ else:
+ # Add a simple OK button for informational display
+ self.ok_button = QPushButton()
+ self.ok_button.clicked.connect(self._ok_info_only)
+ button_layout.addWidget(self.ok_button)
+
+ main_layout.addLayout(button_layout)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("cookie_help_dialog_title", "Cookie File Instructions"))
+
+ instruction_html = f"""
+ {self._tr("cookie_help_instruction_intro", "To use cookies...
")}
+ {self._tr("cookie_help_how_to_get_title", "How to get cookies.txt:
")}
+
+ {self._tr("cookie_help_step1_extension_intro", "- Install extension...
")}
+ {self._tr("cookie_help_step2_login", "- Go to website...
")}
+ {self._tr("cookie_help_step3_click_icon", "- Click icon...
")}
+ {self._tr("cookie_help_step4_export", "- Click export...
")}
+ {self._tr("cookie_help_step5_save_file", "- Save file...
")}
+ {self._tr("cookie_help_step6_app_intro", "- In this application:
")}
+ {self._tr("cookie_help_step6a_checkbox", "- Ensure checkbox...
")}
+ {self._tr("cookie_help_step6b_browse", "- Click browse...
")}
+ {self._tr("cookie_help_step6c_select", "- Select file...
")}
+
+ {self._tr("cookie_help_alternative_paste", "Alternatively, paste...
")}
+ """
+ self.info_label.setText(instruction_html)
+
+ if self.offer_download_without_option:
+ self.download_without_button.setText(self._tr("cookie_help_proceed_without_button", "Download without Cookies"))
+ self.cancel_button.setText(self._tr("cookie_help_cancel_download_button", "Cancel Download"))
+ else:
+ self.ok_button.setText(self._tr("ok_button", "OK"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ def _proceed_without_cookies(self):
+ """Handles the user choice to proceed without using cookies."""
+ self.user_choice = self.CHOICE_PROCEED_WITHOUT_COOKIES
+ self.accept()
+
+ def _cancel_download(self):
+ """Handles the user choice to cancel the download."""
+ self.user_choice = self.CHOICE_CANCEL_DOWNLOAD
+ self.reject()
+
+ def _ok_info_only(self):
+ """Handles the acknowledgment when the dialog is purely informational."""
+ self.user_choice = self.CHOICE_OK_INFO_ONLY
+ self.accept()
diff --git a/src/ui/dialogs/DownloadExtractedLinksDialog.py b/src/ui/dialogs/DownloadExtractedLinksDialog.py
new file mode 100644
index 0000000..d635153
--- /dev/null
+++ b/src/ui/dialogs/DownloadExtractedLinksDialog.py
@@ -0,0 +1,183 @@
+# --- Standard Library Imports ---
+from collections import defaultdict
+
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import pyqtSignal, Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
+ QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
+)
+
+# --- Local Application Imports ---
+# This assumes the new project structure is in place.
+from ...i18n.translator import get_translation
+# get_app_icon_object is defined in the main window module in this refactoring plan.
+from ..main_window import get_app_icon_object
+
+
+class DownloadExtractedLinksDialog(QDialog):
+ """
+ A dialog to select and initiate the download for extracted, supported links
+ from external cloud services like Mega, Google Drive, and Dropbox.
+ """
+
+ # Signal emitted with a list of selected link information dictionaries
+ download_requested = pyqtSignal(list)
+
+ def __init__(self, links_data, parent_app, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ links_data (list): A list of dictionaries, each containing info about an extracted link.
+ parent_app (DownloaderApp): A reference to the main application window for theming and translations.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.links_data = links_data
+ self.parent_app = parent_app
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically based on the parent window's size
+ if parent:
+ parent_width = parent.width()
+ parent_height = parent.height()
+ # Use a scaling factor for different screen resolutions
+ screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
+ scale_factor = screen_height / 768.0
+
+ base_min_w, base_min_h = 500, 400
+ 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)
+ self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
+ max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ layout = QVBoxLayout(self)
+
+ self.main_info_label = QLabel()
+ self.main_info_label.setAlignment(Qt.AlignHCenter | Qt.AlignTop)
+ self.main_info_label.setWordWrap(True)
+ layout.addWidget(self.main_info_label)
+
+ self.links_list_widget = QListWidget()
+ self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
+ self._populate_list()
+ layout.addWidget(self.links_list_widget)
+
+ # --- Control Buttons ---
+ button_layout = QHBoxLayout()
+ self.select_all_button = QPushButton()
+ self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
+ button_layout.addWidget(self.select_all_button)
+
+ self.deselect_all_button = QPushButton()
+ self.deselect_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Unchecked))
+ button_layout.addWidget(self.deselect_all_button)
+ button_layout.addStretch()
+
+ self.download_button = QPushButton()
+ self.download_button.clicked.connect(self._handle_download_selected)
+ self.download_button.setDefault(True)
+ button_layout.addWidget(self.download_button)
+
+ self.cancel_button = QPushButton()
+ self.cancel_button.clicked.connect(self.reject)
+ button_layout.addWidget(self.cancel_button)
+ layout.addLayout(button_layout)
+
+ def _populate_list(self):
+ """Populates the list widget with the provided links, grouped by post title."""
+ grouped_links = defaultdict(list)
+ for link_info_item in self.links_data:
+ post_title_for_group = link_info_item.get('title', 'Untitled Post')
+ grouped_links[post_title_for_group].append(link_info_item)
+
+ sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
+
+ for post_title_key in sorted_post_titles:
+ # Add a non-selectable header for each post
+ header_item = QListWidgetItem(f"{post_title_key}")
+ header_item.setFlags(Qt.NoItemFlags)
+ font = header_item.font()
+ font.setBold(True)
+ font.setPointSize(font.pointSize() + 1)
+ header_item.setFont(font)
+ self.links_list_widget.addItem(header_item)
+
+ # Add checkable items for each link within that post
+ for link_info_data in grouped_links[post_title_key]:
+ platform_display = link_info_data.get('platform', 'unknown').upper()
+ display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
+ item = QListWidgetItem(display_text)
+ item.setData(Qt.UserRole, link_info_data)
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
+ item.setCheckState(Qt.Checked)
+ self.links_list_widget.addItem(item)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on current app language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("download_external_links_dialog_title", "Download Selected External Links"))
+ self.main_info_label.setText(self._tr("download_external_links_dialog_main_label", "Found {count} supported link(s)...").format(count=len(self.links_data)))
+ self.select_all_button.setText(self._tr("select_all_button_text", "Select All"))
+ self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
+ self.download_button.setText(self._tr("download_selected_button_text", "Download Selected"))
+ self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ is_dark_theme = self.parent() and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark"
+
+ if is_dark_theme and hasattr(self.parent_app, 'get_dark_theme'):
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ # Set header text color based on theme
+ header_color = Qt.cyan if is_dark_theme else Qt.blue
+ for i in range(self.links_list_widget.count()):
+ item = self.links_list_widget.item(i)
+ # Headers are not checkable
+ if not item.flags() & Qt.ItemIsUserCheckable:
+ item.setForeground(header_color)
+
+ def _set_all_items_checked(self, check_state):
+ """Sets the checked state for all checkable items in the list."""
+ for i in range(self.links_list_widget.count()):
+ item = self.links_list_widget.item(i)
+ if item.flags() & Qt.ItemIsUserCheckable:
+ item.setCheckState(check_state)
+
+ def _handle_download_selected(self):
+ """Gathers selected links and emits the download_requested signal."""
+ selected_links = []
+ for i in range(self.links_list_widget.count()):
+ item = self.links_list_widget.item(i)
+ if item.flags() & Qt.ItemIsUserCheckable and item.checkState() == Qt.Checked and item.data(Qt.UserRole) is not None:
+ selected_links.append(item.data(Qt.UserRole))
+
+ if selected_links:
+ self.download_requested.emit(selected_links)
+ self.accept()
+ else:
+ QMessageBox.information(
+ self,
+ self._tr("no_selection_title", "No Selection"),
+ self._tr("no_selection_message_links", "Please select at least one link to download.")
+ )
diff --git a/src/ui/dialogs/DownloadHistoryDialog.py b/src/ui/dialogs/DownloadHistoryDialog.py
new file mode 100644
index 0000000..b64598b
--- /dev/null
+++ b/src/ui/dialogs/DownloadHistoryDialog.py
@@ -0,0 +1,219 @@
+# --- Standard Library Imports ---
+import os
+import time
+
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import Qt, QStandardPaths, QTimer
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
+ QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
+ QFileDialog, QMessageBox
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+
+
+class DownloadHistoryDialog(QDialog):
+ """
+ Dialog to display download history, showing the last few downloaded files
+ and the first posts processed in the current session. It also allows
+ exporting this history to a text file.
+ """
+
+ def __init__(self, last_downloaded_entries, first_processed_entries, parent_app, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ last_downloaded_entries (list): A list of dicts for the last few files.
+ first_processed_entries (list): A list of dicts for the first few posts.
+ parent_app (DownloaderApp): A reference to the main application window.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app
+ self.last_3_downloaded_entries = last_downloaded_entries
+ self.first_processed_entries = first_processed_entries
+ self.setModal(True)
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically
+ screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
+ scale_factor = screen_height / 1080.0
+ base_min_w, base_min_h = 600, 450
+ scaled_min_w = int(base_min_w * 1.5 * scale_factor)
+ scaled_min_h = int(base_min_h * scale_factor)
+ self.setMinimumSize(scaled_min_w, scaled_min_h)
+ self.resize(scaled_min_w, scaled_min_h + 100) # Give it a bit more height
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ dialog_layout = QVBoxLayout(self)
+ self.setLayout(dialog_layout)
+
+ self.main_splitter = QSplitter(Qt.Horizontal)
+ dialog_layout.addWidget(self.main_splitter)
+
+ # --- Left Pane (Last Downloaded Files) ---
+ left_pane_widget = self._create_history_pane(
+ self.last_3_downloaded_entries,
+ "history_last_downloaded_header", "Last 3 Files Downloaded:",
+ self._format_last_downloaded_entry
+ )
+ self.main_splitter.addWidget(left_pane_widget)
+
+ # --- Right Pane (First Processed Posts) ---
+ right_pane_widget = self._create_history_pane(
+ self.first_processed_entries,
+ "first_files_processed_header", "First {count} Posts Processed This Session:",
+ self._format_first_processed_entry,
+ count=len(self.first_processed_entries)
+ )
+ self.main_splitter.addWidget(right_pane_widget)
+
+ # --- Bottom Buttons ---
+ bottom_button_layout = QHBoxLayout()
+ self.save_history_button = QPushButton()
+ self.save_history_button.clicked.connect(self._save_history_to_txt)
+ bottom_button_layout.addStretch(1)
+ bottom_button_layout.addWidget(self.save_history_button)
+ dialog_layout.addLayout(bottom_button_layout)
+
+ # Set splitter sizes after the dialog is shown to ensure correct proportions
+ QTimer.singleShot(0, lambda: self.main_splitter.setSizes([self.width() // 2, self.width() // 2]))
+
+ def _create_history_pane(self, entries, header_key, header_default, formatter_func, **kwargs):
+ """Creates a generic pane for displaying a list of history entries."""
+ pane_widget = QWidget()
+ layout = QVBoxLayout(pane_widget)
+ header_text = self._tr(header_key, header_default).format(**kwargs)
+ header_label = QLabel(header_text)
+ header_label.setAlignment(Qt.AlignCenter)
+ layout.addWidget(header_label)
+
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_content_widget = QWidget()
+ scroll_layout = QVBoxLayout(scroll_content_widget)
+
+ if not entries:
+ no_history_label = QLabel(self._tr("no_download_history_header", "No History Yet"))
+ no_history_label.setAlignment(Qt.AlignCenter)
+ scroll_layout.addWidget(no_history_label)
+ else:
+ for entry in entries:
+ group_box, details_label = formatter_func(entry)
+ group_layout = QVBoxLayout(group_box)
+ group_layout.addWidget(details_label)
+ scroll_layout.addWidget(group_box)
+
+ scroll_area.setWidget(scroll_content_widget)
+ layout.addWidget(scroll_area)
+ return pane_widget
+
+ def _format_last_downloaded_entry(self, entry):
+ """Formats a single entry for the 'Last Downloaded Files' pane."""
+ group_box = QGroupBox(f"{self._tr('history_file_label', 'File:')} {entry.get('disk_filename', 'N/A')}")
+ details_text = (
+ f"{self._tr('history_from_post_label', 'From Post:')} {entry.get('post_title', 'N/A')} (ID: {entry.get('post_id', 'N/A')})
"
+ f"{self._tr('history_creator_series_label', 'Creator/Series:')} {entry.get('creator_display_name', 'N/A')}
"
+ f"{self._tr('history_post_uploaded_label', 'Post Uploaded:')} {entry.get('upload_date_str', 'N/A')}
"
+ f"{self._tr('history_file_downloaded_label', 'File Downloaded:')} {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entry.get('download_timestamp', 0)))}
"
+ f"{self._tr('history_saved_in_folder_label', 'Saved In Folder:')} {entry.get('download_path', 'N/A')}"
+ )
+ details_label = QLabel(details_text)
+ details_label.setWordWrap(True)
+ details_label.setTextFormat(Qt.RichText)
+ return group_box, details_label
+
+ def _format_first_processed_entry(self, entry):
+ """Formats a single entry for the 'First Processed Posts' pane."""
+ group_box = QGroupBox(f"{self._tr('history_post_label', 'Post:')} {entry.get('post_title', 'N/A')} (ID: {entry.get('post_id', 'N/A')})")
+ details_text = (
+ f"{self._tr('history_creator_label', 'Creator:')} {entry.get('creator_name', 'N/A')}
"
+ f"{self._tr('history_top_file_label', 'Top File:')} {entry.get('top_file_name', 'N/A')}
"
+ f"{self._tr('history_num_files_label', 'Num Files in Post:')} {entry.get('num_files', 0)}
"
+ f"{self._tr('history_post_uploaded_label', 'Post Uploaded:')} {entry.get('upload_date_str', 'N/A')}
"
+ f"{self._tr('history_processed_on_label', 'Processed On:')} {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entry.get('download_date_timestamp', 0)))}
"
+ f"{self._tr('history_saved_to_folder_label', 'Saved To Folder:')} {entry.get('download_location', 'N/A')}"
+ )
+ details_label = QLabel(details_text)
+ details_label.setWordWrap(True)
+ details_label.setTextFormat(Qt.RichText)
+ return group_box, details_label
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("download_history_dialog_title_combined", "Download History"))
+ self.save_history_button.setText(self._tr("history_save_button_text", "Save History to .txt"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ def _save_history_to_txt(self):
+ """Saves the displayed history content to a user-selected text file."""
+ if not self.last_3_downloaded_entries and not self.first_processed_entries:
+ QMessageBox.information(
+ self,
+ self._tr("no_download_history_header", "No History Yet"),
+ self._tr("history_nothing_to_save_message", "There is no history to save.")
+ )
+ return
+
+ # Suggest saving in the main download directory or Documents as a fallback
+ main_download_dir = self.parent_app.dir_input.text().strip()
+ default_save_dir = ""
+ if main_download_dir and os.path.isdir(main_download_dir):
+ default_save_dir = main_download_dir
+ else:
+ default_save_dir = QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation) or self.parent_app.app_base_dir
+
+ default_filepath = os.path.join(default_save_dir, "download_history.txt")
+
+ filepath, _ = QFileDialog.getSaveFileName(
+ self,
+ self._tr("history_save_dialog_title", "Save Download History"),
+ default_filepath,
+ "Text Files (*.txt);;All Files (*)"
+ )
+
+ if not filepath:
+ return
+
+ # Build the text content
+ history_content = []
+ # ... logic for formatting the text content would go here ...
+
+ try:
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write("\n".join(history_content))
+ QMessageBox.information(
+ self,
+ self._tr("history_export_success_title", "History Export Successful"),
+ self._tr("history_export_success_message", "Successfully exported to:\n{filepath}").format(filepath=filepath)
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ self._tr("history_export_error_title", "History Export Error"),
+ self._tr("history_export_error_message", "Could not export: {error}").format(error=str(e))
+ )
diff --git a/src/ui/dialogs/EmptyPopupDialog.py b/src/ui/dialogs/EmptyPopupDialog.py
new file mode 100644
index 0000000..270cf3a
--- /dev/null
+++ b/src/ui/dialogs/EmptyPopupDialog.py
@@ -0,0 +1,1000 @@
+# --- Standard Library Imports ---
+import json
+import os
+import sys
+import threading
+import time
+import unicodedata
+from collections import defaultdict
+from urllib.parse import urlparse
+
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
+ QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
+ QSplitter, QProgressBar, QWidget
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+from ...core.api_client import download_from_api
+from ...utils.network_utils import extract_post_info, prepare_cookies_for_request
+
+
+class PostsFetcherThread (QThread ):
+ status_update =pyqtSignal (str )
+ posts_fetched_signal =pyqtSignal (object ,list )
+ fetch_error_signal =pyqtSignal (object ,str )
+ finished_signal =pyqtSignal ()
+
+ def __init__ (self ,creators_to_fetch ,parent_dialog_ref ):
+ super ().__init__ ()
+ self .creators_to_fetch =creators_to_fetch
+ self .parent_dialog =parent_dialog_ref
+ self .cancellation_flag =threading .Event ()
+
+ def cancel (self ):
+ self .cancellation_flag .set ()
+ self .status_update .emit (self .parent_dialog ._tr ("post_fetch_cancelled_status","Post fetching cancellation requested..."))
+
+ def run (self ):
+ if not self .creators_to_fetch :
+ self .status_update .emit (self .parent_dialog ._tr ("no_creators_to_fetch_status","No creators selected to fetch posts for."))
+ self .finished_signal .emit ()
+ return
+
+ for creator_data in self .creators_to_fetch :
+ if self .cancellation_flag .is_set ():
+ break
+
+ creator_name =creator_data .get ('name','Unknown Creator')
+ service =creator_data .get ('service')
+ user_id =creator_data .get ('id')
+
+ print(f"[DEBUG] Fetching posts for: name={creator_name}, service={service}, user_id={user_id}")
+
+ if not service or not user_id :
+ self .fetch_error_signal .emit (creator_data ,f"Missing service or ID for {creator_name }")
+ continue
+
+ self .status_update .emit (self .parent_dialog ._tr ("fetching_posts_for_creator_status_all_pages","Fetching all posts for {creator_name} ({service})... This may take a while.").format (creator_name =creator_name ,service =service ))
+
+ domain =self .parent_dialog ._get_domain_for_service (service )
+ api_url_base =f"https://{domain }/api/v1/{service }/user/{user_id }"
+ print(f"[DEBUG] API URL: {api_url_base}")
+
+
+ use_cookie_param =False
+ cookie_text_param =""
+ selected_cookie_file_param =None
+ app_base_dir_param =None
+
+ if self .parent_dialog .parent_app :
+ app =self .parent_dialog .parent_app
+ use_cookie_param =app .use_cookie_checkbox .isChecked ()
+ cookie_text_param =app .cookie_text_input .text ().strip ()
+ selected_cookie_file_param =app .selected_cookie_filepath
+ app_base_dir_param =app .app_base_dir
+
+ all_posts_for_this_creator =[]
+ try :
+ post_generator =download_from_api (
+ api_url_base ,
+ logger =lambda msg :self .status_update .emit (f"[API Fetch - {creator_name }] {msg }"),
+
+ use_cookie =use_cookie_param ,
+ cookie_text =cookie_text_param ,
+ selected_cookie_file =selected_cookie_file_param ,
+ app_base_dir =app_base_dir_param ,
+ manga_filename_style_for_sort_check =None ,
+ cancellation_event =self .cancellation_flag
+ )
+
+ for posts_batch in post_generator :
+ if self .cancellation_flag .is_set ():
+ self .status_update .emit (f"Post fetching for {creator_name } cancelled during pagination.")
+ break
+ all_posts_for_this_creator .extend (posts_batch )
+ print(f"[DEBUG] Fetched batch: {len(posts_batch)} posts, total so far: {len(all_posts_for_this_creator)}")
+ self .status_update .emit (f"Fetched {len (all_posts_for_this_creator )} posts so far for {creator_name }...")
+
+ print(f"[DEBUG] Finished fetching for {creator_name}: {len(all_posts_for_this_creator)} posts total.")
+ if not self .cancellation_flag .is_set ():
+ self .posts_fetched_signal .emit (creator_data ,all_posts_for_this_creator )
+ self .status_update .emit (f"Finished fetching {len (all_posts_for_this_creator )} posts for {creator_name }.")
+ else :
+ self .posts_fetched_signal .emit (creator_data ,all_posts_for_this_creator )
+ self .status_update .emit (f"Fetching for {creator_name } cancelled. {len (all_posts_for_this_creator )} posts collected.")
+
+ except RuntimeError as e :
+ print(f"[DEBUG] RuntimeError for {creator_name}: {e}")
+ if "cancelled by user"in str (e ).lower ()or self .cancellation_flag .is_set ():
+ self .status_update .emit (f"Post fetching for {creator_name } cancelled: {e }")
+ self .posts_fetched_signal .emit (creator_data ,all_posts_for_this_creator )
+ else :
+ self .fetch_error_signal .emit (creator_data ,f"Runtime error fetching posts for {creator_name }: {e }")
+ except Exception as e :
+ print(f"[DEBUG] Exception for {creator_name}: {e}")
+ self .fetch_error_signal .emit (creator_data ,f"Error fetching posts for {creator_name }: {e }")
+
+ if self .cancellation_flag .is_set ():
+ break
+ QThread .msleep (200 )
+
+ if self .cancellation_flag .is_set ():
+ self .status_update .emit (self .parent_dialog ._tr ("post_fetch_cancelled_status_done","Post fetching cancelled."))
+ else :
+ self .status_update .emit (self .parent_dialog ._tr ("post_fetch_finished_status","Finished fetching posts for selected creators."))
+ self .finished_signal .emit ()
+
+class EmptyPopupDialog (QDialog ):
+ """A simple empty popup dialog."""
+ SCOPE_CHARACTERS ="Characters"
+ INITIAL_LOAD_LIMIT =200
+ SCOPE_CREATORS ="Creators"
+
+
+ def __init__ (self ,app_base_dir ,parent_app_ref ,parent =None ):
+ super ().__init__ (parent )
+ self .setMinimumSize (400 ,300 )
+ screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
+ scale_factor =screen_height /768.0
+ self .setMinimumSize (int (400 *scale_factor ),int (300 *scale_factor ))
+
+ self .parent_app =parent_app_ref
+ self .current_scope_mode =self .SCOPE_CHARACTERS
+ self .app_base_dir =app_base_dir
+
+ app_icon =get_app_icon_object ()
+ if app_icon and not app_icon .isNull ():
+ self .setWindowIcon (app_icon )
+ self .selected_creators_for_queue =[]
+ self .globally_selected_creators ={}
+ self .fetched_posts_data ={}
+ self .post_fetch_thread =None
+ self .TITLE_COLUMN_WIDTH_FOR_POSTS =70
+ self .globally_selected_post_ids =set ()
+ self ._is_scrolling_titles =False
+ self ._is_scrolling_dates =False
+
+
+ dialog_layout =QHBoxLayout (self )
+ self .setLayout (dialog_layout )
+
+
+
+ self .left_pane_widget =QWidget ()
+ left_pane_layout =QVBoxLayout (self .left_pane_widget )
+
+
+ search_fetch_layout =QHBoxLayout ()
+ self .search_input =QLineEdit ()
+ self .search_input .textChanged .connect (self ._filter_list )
+ search_fetch_layout .addWidget (self .search_input ,1 )
+ self .fetch_posts_button =QPushButton ()
+ self .fetch_posts_button .setEnabled (False )
+ self .fetch_posts_button .clicked .connect (self ._handle_fetch_posts_click )
+ search_fetch_layout .addWidget (self .fetch_posts_button )
+ left_pane_layout .addLayout (search_fetch_layout )
+
+ self .progress_bar =QProgressBar ()
+ self .progress_bar .setRange (0 ,0 )
+ self .progress_bar .setTextVisible (False )
+ self .progress_bar .setVisible (False )
+ left_pane_layout .addWidget (self .progress_bar )
+
+ self .list_widget =QListWidget ()
+ self .list_widget .itemChanged .connect (self ._handle_item_check_changed )
+ left_pane_layout .addWidget (self .list_widget )
+
+
+ left_bottom_buttons_layout =QHBoxLayout ()
+ self .add_selected_button =QPushButton ()
+ self .add_selected_button .setToolTip (
+ "Add Selected Creators to URL Input\n\n"
+ "Adds the names of all checked creators to the main URL input field,\n"
+ "comma-separated, and closes this dialog."
+ )
+ self .add_selected_button .clicked .connect (self ._handle_add_selected )
+ self .add_selected_button .setDefault (True )
+ left_bottom_buttons_layout .addWidget (self .add_selected_button )
+ self .scope_button =QPushButton ()
+ 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 .right_pane_widget =QWidget ()
+ right_pane_layout =QVBoxLayout (self .right_pane_widget )
+
+ self .posts_area_title_label =QLabel ("Fetched Posts")
+ self .posts_area_title_label .setAlignment (Qt .AlignCenter )
+ right_pane_layout .addWidget (self .posts_area_title_label )
+
+ self .posts_search_input =QLineEdit ()
+ self .posts_search_input .setVisible (False )
+
+ self .posts_search_input .textChanged .connect (self ._filter_fetched_posts_list )
+ right_pane_layout .addWidget (self .posts_search_input )
+
+
+ posts_headers_layout =QHBoxLayout ()
+ self .posts_title_header_label =QLabel ()
+ self .posts_title_header_label .setStyleSheet ("font-weight: bold; padding-left: 20px;")
+ posts_headers_layout .addWidget (self .posts_title_header_label ,7 )
+
+ self .posts_date_header_label =QLabel ()
+ self .posts_date_header_label .setStyleSheet ("font-weight: bold;")
+ posts_headers_layout .addWidget (self .posts_date_header_label ,3 )
+ right_pane_layout .addLayout (posts_headers_layout )
+
+
+
+ self .posts_content_splitter =QSplitter (Qt .Horizontal )
+
+ self .posts_title_list_widget =QListWidget ()
+ self .posts_title_list_widget .itemChanged .connect (self ._handle_post_item_check_changed )
+ self .posts_title_list_widget .setAlternatingRowColors (True )
+ self .posts_content_splitter .addWidget (self .posts_title_list_widget )
+
+ self .posts_date_list_widget =QListWidget ()
+ self .posts_date_list_widget .setSelectionMode (QAbstractItemView .NoSelection )
+ self .posts_date_list_widget .setAlternatingRowColors (True )
+ self .posts_date_list_widget .setHorizontalScrollBarPolicy (Qt .ScrollBarAlwaysOff )
+ self .posts_content_splitter .addWidget (self .posts_date_list_widget )
+
+ right_pane_layout .addWidget (self .posts_content_splitter ,1 )
+
+ posts_buttons_top_layout =QHBoxLayout ()
+ self .posts_select_all_button =QPushButton ()
+ self .posts_select_all_button .clicked .connect (self ._handle_posts_select_all )
+ posts_buttons_top_layout .addWidget (self .posts_select_all_button )
+
+ self .posts_deselect_all_button =QPushButton ()
+ self .posts_deselect_all_button .clicked .connect (self ._handle_posts_deselect_all )
+ posts_buttons_top_layout .addWidget (self .posts_deselect_all_button )
+ right_pane_layout .addLayout (posts_buttons_top_layout )
+
+ posts_buttons_bottom_layout =QHBoxLayout ()
+ self .posts_add_selected_button =QPushButton ()
+ self .posts_add_selected_button .clicked .connect (self ._handle_posts_add_selected_to_queue )
+ posts_buttons_bottom_layout .addWidget (self .posts_add_selected_button )
+
+ self .posts_close_button =QPushButton ()
+ self .posts_close_button .clicked .connect (self ._handle_posts_close_view )
+ posts_buttons_bottom_layout .addWidget (self .posts_close_button )
+ right_pane_layout .addLayout (posts_buttons_bottom_layout )
+
+ self .right_pane_widget .hide ()
+
+
+
+
+
+ self .main_splitter =QSplitter (Qt .Horizontal )
+ self .main_splitter .addWidget (self .left_pane_widget )
+ self .main_splitter .addWidget (self .right_pane_widget )
+ self .main_splitter .setCollapsible (0 ,False )
+ self .main_splitter .setCollapsible (1 ,True )
+
+
+ self .posts_title_list_widget .verticalScrollBar ().valueChanged .connect (self ._sync_scroll_dates )
+ self .posts_date_list_widget .verticalScrollBar ().valueChanged .connect (self ._sync_scroll_titles )
+ dialog_layout .addWidget (self .main_splitter )
+
+ self .original_size =self .sizeHint ()
+ self .main_splitter .setSizes ([int (self .width ()*scale_factor ),0 ])
+
+ self ._retranslate_ui ()
+
+ if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
+ self .setStyleSheet (self .parent_app .get_dark_theme ())
+
+
+ self .resize (int ((self .original_size .width ()+50 )*scale_factor ),int ((self .original_size .height ()+100 )*scale_factor ))
+
+ QTimer .singleShot (0 ,self ._perform_initial_load )
+
+ def _center_on_screen (self ):
+ """Centers the dialog on the parent's screen or the primary screen."""
+ if self .parent_app :
+ parent_rect =self .parent_app .frameGeometry ()
+ self .move (parent_rect .center ()-self .rect ().center ())
+ else :
+ try :
+ screen_geo =QApplication .primaryScreen ().availableGeometry ()
+ self .move (screen_geo .center ()-self .rect ().center ())
+ except AttributeError :
+ pass
+
+ def _handle_fetch_posts_click (self ):
+ selected_creators =list (self .globally_selected_creators .values ())
+ print(f"[DEBUG] Selected creators for fetch: {selected_creators}")
+ if not selected_creators :
+ QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
+ "Please select at least one creator to fetch posts for.")
+ return
+
+ if self .parent_app :
+ parent_geometry =self .parent_app .geometry ()
+ new_width =int (parent_geometry .width ()*0.75 )
+ new_height =int (parent_geometry .height ()*0.80 )
+ self .resize (new_width ,new_height )
+ self ._center_on_screen ()
+
+ self .right_pane_widget .show ()
+ QTimer .singleShot (10 ,lambda :self .main_splitter .setSizes ([int (self .width ()*0.3 ),int (self .width ()*0.7 )]))
+
+ QTimer .singleShot (20 ,lambda :self .posts_content_splitter .setSizes ([int (self .posts_content_splitter .width ()*0.7 ),int (self .posts_content_splitter .width ()*0.3 )]))
+ self .add_selected_button .setEnabled (False )
+ self .globally_selected_post_ids .clear ()
+ self .posts_search_input .setVisible (True )
+ self .setWindowTitle (self ._tr ("creator_popup_title_fetching","Creator Posts"))
+
+ self .fetch_posts_button .setEnabled (False )
+ self .posts_title_list_widget .clear ()
+ self .posts_date_list_widget .clear ()
+ self .fetched_posts_data .clear ()
+ self .posts_area_title_label .setText (self ._tr ("fav_posts_loading_status","Loading favorite posts..."))
+ self .posts_title_list_widget .itemChanged .connect (self ._handle_post_item_check_changed )
+ self .progress_bar .setVisible (True )
+
+ if self .post_fetch_thread and self .post_fetch_thread .isRunning ():
+ self .post_fetch_thread .cancel ()
+ self .post_fetch_thread .wait ()
+ print(f"[DEBUG] Starting PostsFetcherThread with creators: {selected_creators}")
+ self .post_fetch_thread =PostsFetcherThread (selected_creators ,self )
+ self .post_fetch_thread .status_update .connect (self ._handle_fetch_status_update )
+ self .post_fetch_thread .posts_fetched_signal .connect (self ._handle_posts_fetched )
+ self .post_fetch_thread .fetch_error_signal .connect (self ._handle_fetch_error )
+ self .post_fetch_thread .finished_signal .connect (self ._handle_fetch_finished )
+ self .post_fetch_thread .start ()
+
+ def _tr (self ,key ,default_text =""):
+ """Helper to get translation based on current app language."""
+ if callable (get_translation )and self .parent_app :
+ return get_translation (self .parent_app .current_selected_language ,key ,default_text )
+ return default_text
+
+ def _retranslate_ui (self ):
+ self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
+ self .search_input .setPlaceholderText (self ._tr ("creator_popup_search_placeholder","Search by name, service, or paste creator URL..."))
+ 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 .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))
+
+ self .posts_title_header_label .setText (self ._tr ("column_header_post_title","Post Title"))
+ self .posts_date_header_label .setText (self ._tr ("column_header_date_uploaded","Date Uploaded"))
+
+ self .posts_area_title_label .setText (self ._tr ("creator_popup_posts_area_title","Fetched Posts"))
+ self .posts_select_all_button .setText (self ._tr ("select_all_button_text","Select All"))
+ self .posts_deselect_all_button .setText (self ._tr ("deselect_all_button_text","Deselect All"))
+ self .posts_add_selected_button .setText (self ._tr ("creator_popup_add_posts_to_queue_button","Add Selected Posts to Queue"))
+ self .posts_close_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
+
+ def _sync_scroll_dates (self ,value ):
+ if not self ._is_scrolling_titles :
+ self ._is_scrolling_dates =True
+ self .posts_date_list_widget .verticalScrollBar ().setValue (value )
+ self ._is_scrolling_dates =False
+
+ def _sync_scroll_titles (self ,value ):
+ if not self ._is_scrolling_dates :
+ self ._is_scrolling_titles =True
+ self .posts_title_list_widget .verticalScrollBar ().setValue (value )
+ self ._is_scrolling_titles =False
+
+ def _perform_initial_load (self ):
+ """Called by QTimer to load data after dialog is shown."""
+ self ._load_creators_from_json ()
+
+
+
+ def _load_creators_from_json (self ):
+ """Loads creators from creators.json and populates the list widget."""
+ self .list_widget .clear ()
+
+ self .progress_bar .setVisible (True )
+ QCoreApplication .processEvents ()
+ if not self .isVisible ():return
+ # Always resolve project root relative to this file
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
+ creators_file_path = os.path.join(project_root, "data", "creators.json")
+
+ if not os .path .exists (creators_file_path ):
+ self .list_widget .addItem (f"Error: creators.json not found at {creators_file_path }")
+ self .all_creators_data =[]
+ self .progress_bar .setVisible (False )
+ QCoreApplication .processEvents ()
+ return
+
+ try :
+ with open (creators_file_path ,'r',encoding ='utf-8')as f :
+ data =json .load (f )
+ QCoreApplication .processEvents ()
+ if not self .isVisible ():return
+
+ raw_data_list =[]
+ if isinstance (data ,list )and len (data )>0 and isinstance (data [0 ],list ):
+ raw_data_list =data [0 ]
+ elif isinstance (data ,list )and all (isinstance (item ,dict )for item in data ):
+ raw_data_list =data
+ else :
+ self .list_widget .addItem ("Error: Invalid format in creators.json.")
+ self .all_creators_data =[]
+ self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
+
+
+ unique_creators_map ={}
+ duplicates_skipped_count =0
+ for creator_entry in raw_data_list :
+ service =creator_entry .get ('service')
+ creator_id =creator_entry .get ('id')
+ name_for_log =creator_entry .get ('name','Unknown')
+
+ if service and creator_id :
+ key =(str (service ).lower ().strip (),str (creator_id ).strip ())
+ if key not in unique_creators_map :
+ unique_creators_map [key ]=creator_entry
+ else :
+ duplicates_skipped_count +=1
+ else :
+ print (f"Warning: Creator entry in creators.json missing service or ID: {name_for_log }")
+
+ self .all_creators_data =list (unique_creators_map .values ())
+ if duplicates_skipped_count >0 :
+ print (f"INFO (Creator Popup): Skipped {duplicates_skipped_count } duplicate creator entries from creators.json based on (service, id).")
+
+ self .all_creators_data .sort (key =lambda c :(-c .get ('favorited',0 ),c .get ('name','').lower ()))
+ QCoreApplication .processEvents ()
+ if not self .isVisible ():return
+
+ except json .JSONDecodeError :
+ self .list_widget .addItem ("Error: Could not parse creators.json.")
+ self .all_creators_data =[]
+ self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
+ except Exception as e :
+ self .list_widget .addItem (f"Error loading creators: {e }")
+ self .all_creators_data =[]
+ self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
+
+ self ._filter_list ()
+
+ def _populate_list_widget (self ,creators_to_display ):
+ """Clears and populates the list widget with the given creator data."""
+ self .list_widget .blockSignals (True )
+ self .list_widget .clear ()
+
+ if not creators_to_display :
+ self .list_widget .blockSignals (False )
+
+
+
+
+
+
+ return
+
+ CHUNK_SIZE_POPULATE =100
+ for i in range (0 ,len (creators_to_display ),CHUNK_SIZE_POPULATE ):
+ if not self .isVisible ():
+ self .list_widget .blockSignals (False )
+ return
+
+ chunk =creators_to_display [i :i +CHUNK_SIZE_POPULATE ]
+ for creator in chunk :
+ creator_name_raw =creator .get ('name')
+ display_creator_name =creator_name_raw .strip ()if isinstance (creator_name_raw ,str )and creator_name_raw .strip ()else "Unknown Creator"
+ service_display_name =creator .get ('service','N/A').capitalize ()
+ display_text =f"{display_creator_name } ({service_display_name })"
+ item =QListWidgetItem (display_text )
+ item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
+ item .setData (Qt .UserRole ,creator )
+ service =creator .get ('service')
+ creator_id =creator .get ('id')
+ if service is not None and creator_id is not None :
+ unique_key =(str (service ),str (creator_id ))
+ if unique_key in self .globally_selected_creators :
+ item .setCheckState (Qt .Checked )
+ else :
+ item .setCheckState (Qt .Unchecked )
+ else :
+ item .setCheckState (Qt .Unchecked )
+ self .list_widget .addItem (item )
+
+ QCoreApplication .processEvents ()
+
+ self .list_widget .blockSignals (False )
+
+ def _filter_list (self ):
+ """Filters the list widget based on the search input."""
+ raw_search_input =self .search_input .text ()
+
+
+
+ check_search_text_for_empty =raw_search_input .lower ().strip ()
+
+ QCoreApplication .processEvents ()
+ if not self .isVisible ():return
+
+ if not check_search_text_for_empty :
+ self .progress_bar .setVisible (False )
+ creators_to_show =self .all_creators_data [:self .INITIAL_LOAD_LIMIT ]
+ self ._populate_list_widget (creators_to_show )
+ self .search_input .setToolTip ("Search by name, service, or paste creator URL...")
+ QCoreApplication .processEvents ()
+ else :
+ self .progress_bar .setVisible (True )
+ QCoreApplication .processEvents ()
+ if not self .isVisible ():return
+
+ norm_search_casefolded =unicodedata .normalize ('NFKC',raw_search_input ).casefold ().strip ()
+
+ scored_matches =[]
+
+ parsed_service_from_url ,parsed_user_id_from_url ,_ =extract_post_info (raw_search_input )
+
+ if parsed_service_from_url and parsed_user_id_from_url :
+
+ self .search_input .setToolTip (f"Searching for URL: {raw_search_input [:50 ]}...")
+ for creator_data in self .all_creators_data :
+ creator_service_lower =creator_data .get ('service','').lower ()
+
+ creator_id_str_lower =str (creator_data .get ('id','')).lower ()
+
+ if creator_service_lower ==parsed_service_from_url .lower ()and creator_id_str_lower ==parsed_user_id_from_url .lower ():
+ scored_matches .append ((5 ,creator_data ))
+
+ break
+
+ else :
+
+
+ self .search_input .setToolTip ("Searching by name or service...")
+ norm_search_casefolded =unicodedata .normalize ('NFKC',raw_search_input ).casefold ().strip ()
+
+ CHUNK_SIZE_FILTER =500
+ for i in range (0 ,len (self .all_creators_data ),CHUNK_SIZE_FILTER ):
+ if not self .isVisible ():break
+ chunk =self .all_creators_data [i :i +CHUNK_SIZE_FILTER ]
+ for creator_data in chunk :
+ creator_name_raw =creator_data .get ('name','')
+ creator_service_raw =creator_data .get ('service','')
+
+
+ norm_creator_name_casefolded =unicodedata .normalize ('NFKC',creator_name_raw ).casefold ()
+ norm_service_casefolded =unicodedata .normalize ('NFKC',creator_service_raw ).casefold ()
+
+ current_score =0
+
+ if norm_search_casefolded ==norm_creator_name_casefolded :
+ current_score =4
+
+ elif norm_creator_name_casefolded .startswith (norm_search_casefolded ):
+ current_score =3
+
+ elif norm_search_casefolded in norm_creator_name_casefolded :
+ current_score =2
+
+ elif norm_search_casefolded in norm_service_casefolded :
+ current_score =1
+
+ if current_score >0 :
+ scored_matches .append ((current_score ,creator_data ))
+
+ QCoreApplication .processEvents ()
+
+
+
+ scored_matches .sort (key =lambda x :(-x [0 ],unicodedata .normalize ('NFKC',x [1 ].get ('name','')).casefold ()))
+
+
+ final_creators_to_display =[creator_data for score ,creator_data in scored_matches [:20 ]]
+
+ self ._populate_list_widget (final_creators_to_display )
+ self .progress_bar .setVisible (False )
+
+
+ if parsed_service_from_url and parsed_user_id_from_url :
+ if final_creators_to_display :
+ self .search_input .setToolTip (f"Found creator by URL: {final_creators_to_display [0 ].get ('name')}")
+ else :
+ self .search_input .setToolTip (f"URL parsed, but no matching creator found in your creators.json.")
+ else :
+ if final_creators_to_display :
+ self .search_input .setToolTip (f"Showing top {len (final_creators_to_display )} match(es) for '{raw_search_input [:30 ]}...'")
+ else :
+ self .search_input .setToolTip (f"No matches found for '{raw_search_input [:30 ]}...'")
+
+ def _toggle_scope_mode (self ):
+ """Toggles the scope mode and updates the button text."""
+ if self .current_scope_mode ==self .SCOPE_CHARACTERS :
+ self .current_scope_mode =self .SCOPE_CREATORS
+ else :
+ self .current_scope_mode =self .SCOPE_CHARACTERS
+ self ._update_scope_button_text_and_tooltip ()
+
+ def _update_scope_button_text_and_tooltip (self ):
+ if self .current_scope_mode ==self .SCOPE_CHARACTERS :
+ self .scope_button .setText (self ._tr ("creator_popup_scope_characters_button","Scope: Characters"))
+ else :
+ self .scope_button .setText (self ._tr ("creator_popup_scope_creators_button","Scope: Creators"))
+
+ self .scope_button .setToolTip (
+ f"Current Download Scope: {self .current_scope_mode }\n\n"
+ f"Click to toggle between '{self .SCOPE_CHARACTERS }' and '{self .SCOPE_CREATORS }' scopes.\n"
+ f"'{self .SCOPE_CHARACTERS }': Downloads into character-named folders directly in the main Download Location (artists mixed).\n"
+ f"'{self .SCOPE_CREATORS }': Downloads into artist-named subfolders within the main Download Location, then character folders inside those.")
+
+ def _handle_fetch_status_update (self ,message ):
+ if self .parent_app :
+ self .parent_app .log_signal .emit (f"[CreatorPopup Fetch] {message }")
+ self .posts_area_title_label .setText (message )
+
+ def _handle_posts_fetched (self ,creator_info ,posts_list ):
+ creator_key =(creator_info .get ('service'),str (creator_info .get ('id')))
+
+ self .fetched_posts_data [creator_key ]=(creator_info ,posts_list )
+ self ._filter_fetched_posts_list ()
+
+ def _filter_fetched_posts_list (self ):
+ search_text =self .posts_search_input .text ().lower ().strip ()
+
+ data_for_rebuild ={}
+
+ if not self .fetched_posts_data :
+ self .posts_area_title_label .setText (self ._tr ("no_posts_fetched_yet_status","No posts fetched yet."))
+ elif not search_text :
+ data_for_rebuild =self .fetched_posts_data
+
+ total_posts_in_view =sum (len (posts_tuple [1 ])for posts_tuple in data_for_rebuild .values ())
+ if total_posts_in_view >0 :
+ self .posts_area_title_label .setText (self ._tr ("fetched_posts_count_label","Fetched {count} post(s). Select to add to queue.").format (count =total_posts_in_view ))
+ else :
+ self .posts_area_title_label .setText (self ._tr ("no_posts_found_for_selection","No posts found for selected creator(s)."))
+ else :
+ for creator_key ,(creator_data_tuple_part ,posts_list_tuple_part )in self .fetched_posts_data .items ():
+ matching_posts_for_creator =[
+ post for post in posts_list_tuple_part
+ if search_text in post .get ('title','').lower ()
+ ]
+ if matching_posts_for_creator :
+
+ data_for_rebuild [creator_key ]=(creator_data_tuple_part ,matching_posts_for_creator )
+
+
+ total_matching_posts =sum (len (posts_tuple [1 ])for posts_tuple in data_for_rebuild .values ())
+ if total_matching_posts >0 :
+ self .posts_area_title_label .setText (self ._tr ("fetched_posts_count_label_filtered","Displaying {count} post(s) matching filter.").format (count =total_matching_posts ))
+ else :
+ self .posts_area_title_label .setText (self ._tr ("no_posts_match_search_filter","No posts match your search filter."))
+
+ self ._rebuild_posts_list_widget (filtered_data_map =data_for_rebuild )
+
+ def _rebuild_posts_list_widget (self ,filtered_data_map ):
+ self .posts_title_list_widget .blockSignals (True )
+ self .posts_date_list_widget .blockSignals (True )
+ self .posts_title_list_widget .clear ()
+ self .posts_date_list_widget .clear ()
+ data_to_display =filtered_data_map
+
+ if not data_to_display :
+ self .posts_title_list_widget .blockSignals (False )
+ self .posts_date_list_widget .blockSignals (False )
+ return
+
+
+ sorted_creator_keys =sorted (
+ data_to_display .keys (),
+ key =lambda k :data_to_display [k ][0 ].get ('name','').lower ()
+ )
+
+ total_posts_shown =0
+ for creator_key in sorted_creator_keys :
+
+ creator_info_original ,posts_for_this_creator =data_to_display .get (creator_key ,(None ,[]))
+
+ if not creator_info_original or not posts_for_this_creator :
+ continue
+
+ creator_header_item =QListWidgetItem (f"--- {self ._tr ('posts_for_creator_header','Posts for')} {creator_info_original ['name']} ({creator_info_original ['service']}) ---")
+ font =creator_header_item .font ()
+ font .setBold (True )
+ creator_header_item .setFont (font )
+ creator_header_item .setFlags (Qt .NoItemFlags )
+ self .posts_title_list_widget .addItem (creator_header_item )
+ self .posts_date_list_widget .addItem (QListWidgetItem (""))
+
+ for post in posts_for_this_creator :
+ post_title =post .get ('title',self ._tr ('untitled_post_placeholder','Untitled Post'))
+
+
+ date_prefix_str ="[No Date]"
+ published_date_str =post .get ('published')
+ added_date_str =post .get ('added')
+
+ date_to_use_str =None
+ if published_date_str :
+ date_to_use_str =published_date_str
+ elif added_date_str :
+ date_to_use_str =added_date_str
+
+ if date_to_use_str :
+ try :
+
+ formatted_date =date_to_use_str .split ('T')[0 ]
+ date_prefix_str =f"[{formatted_date }]"
+ except Exception :
+ pass
+
+
+ date_display_str ="[No Date]"
+ published_date_str =post .get ('published')
+ added_date_str =post .get ('added')
+
+ date_to_use_str =None
+ if published_date_str :
+ date_to_use_str =published_date_str
+ elif added_date_str :
+ date_to_use_str =added_date_str
+
+ if date_to_use_str :
+ try :
+
+ formatted_date =date_to_use_str .split ('T')[0 ]
+ date_display_str =f"[{formatted_date }]"
+ except Exception :
+ pass
+
+
+ title_item_text =f" {post_title }"
+ item =QListWidgetItem (title_item_text )
+ item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
+ item .setCheckState (Qt .Unchecked )
+ item_data ={
+ 'title':post_title ,
+ 'id':post .get ('id'),
+ 'service':creator_info_original ['service'],
+ 'user_id':creator_info_original ['id'],
+ 'creator_name':creator_info_original ['name'],
+ 'full_post_data':post ,
+ 'date_display_str':date_display_str ,
+ 'published_date_for_sort':date_to_use_str
+ }
+ item .setData (Qt .UserRole ,item_data )
+ post_unique_key =(
+ item_data ['service'],
+ str (item_data ['user_id']),
+ str (item_data ['id'])
+ )
+ if post_unique_key in self .globally_selected_post_ids :
+ item .setCheckState (Qt .Checked )
+ else :
+ item .setCheckState (Qt .Unchecked )
+
+ self .posts_title_list_widget .addItem (item )
+ total_posts_shown +=1
+
+ date_item =QListWidgetItem (f" {date_display_str }")
+ date_item .setFlags (Qt .NoItemFlags )
+ self .posts_date_list_widget .addItem (date_item )
+
+ self .posts_title_list_widget .blockSignals (False )
+ self .posts_date_list_widget .blockSignals (False )
+
+ def _handle_fetch_error (self ,creator_info ,error_message ):
+ creator_name =creator_info .get ('name','Unknown Creator')
+ if self .parent_app :
+ self .parent_app .log_signal .emit (f"[CreatorPopup Fetch ERROR] For {creator_name }: {error_message }")
+
+ self .posts_area_title_label .setText (self ._tr ("fetch_error_for_creator_label","Error fetching for {creator_name}").format (creator_name =creator_name ))
+
+
+ def _handle_fetch_finished (self ):
+ self .fetch_posts_button .setEnabled (True )
+ self .progress_bar .setVisible (False )
+
+ if not self .fetched_posts_data :
+ if self .post_fetch_thread and self .post_fetch_thread .cancellation_flag .is_set ():
+ self .posts_area_title_label .setText (self ._tr ("post_fetch_cancelled_status_done","Post fetching cancelled."))
+ else :
+ self .posts_area_title_label .setText (self ._tr ("failed_to_fetch_or_no_posts_label","Failed to fetch posts or no posts found."))
+ self .posts_search_input .setVisible (False )
+ elif not self .posts_title_list_widget .count ()and not self .posts_search_input .text ().strip ():
+ self .posts_area_title_label .setText (self ._tr ("no_posts_found_for_selection","No posts found for selected creator(s)."))
+ self .posts_search_input .setVisible (True )
+ else :
+ QTimer .singleShot (10 ,lambda :self .posts_content_splitter .setSizes ([int (self .posts_content_splitter .width ()*0.7 ),int (self .posts_content_splitter .width ()*0.3 )]))
+ self .posts_search_input .setVisible (True )
+
+ def _handle_posts_select_all (self ):
+ self .posts_title_list_widget .blockSignals (True )
+ for i in range (self .posts_title_list_widget .count ()):
+ item =self .posts_title_list_widget .item (i )
+ if item .flags ()&Qt .ItemIsUserCheckable :
+ item .setCheckState (Qt .Checked )
+
+
+ item_data =item .data (Qt .UserRole )
+ if item_data :
+ post_unique_key =(
+ item_data ['service'],
+ str (item_data ['user_id']),
+ str (item_data ['id'])
+ )
+ self .globally_selected_post_ids .add (post_unique_key )
+ self .posts_title_list_widget .blockSignals (False )
+
+ def _handle_posts_deselect_all (self ):
+ self .posts_title_list_widget .blockSignals (True )
+ for i in range (self .posts_title_list_widget .count ()):
+ item =self .posts_title_list_widget .item (i )
+ if item .flags ()&Qt .ItemIsUserCheckable :
+ item .setCheckState (Qt .Unchecked )
+ self .globally_selected_post_ids .clear ()
+ self .posts_title_list_widget .blockSignals (False )
+
+ def _handle_post_item_check_changed (self ,item ):
+ if not item or not item .data (Qt .UserRole ):
+ return
+
+ item_data =item .data (Qt .UserRole )
+ post_unique_key =(
+ item_data ['service'],
+ str (item_data ['user_id']),
+ str (item_data ['id'])
+ )
+
+ if item .checkState ()==Qt .Checked :
+ self .globally_selected_post_ids .add (post_unique_key )
+ else :
+ self .globally_selected_post_ids .discard (post_unique_key )
+
+ def _handle_posts_add_selected_to_queue (self ):
+ selected_posts_for_queue =[]
+ if not self .globally_selected_post_ids :
+ QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
+ self ._tr ("select_posts_to_queue_message","Please select at least one post to add to the queue."))
+ return
+
+ for post_key in self .globally_selected_post_ids :
+ service ,user_id_str ,post_id_str =post_key
+ post_data_found =None
+ creator_key_for_fetched_data =(service ,user_id_str )
+
+
+ if creator_key_for_fetched_data in self .fetched_posts_data :
+ _unused_creator_info ,posts_in_list_for_creator =self .fetched_posts_data [creator_key_for_fetched_data ]
+ for post_in_list in posts_in_list_for_creator :
+ if str (post_in_list .get ('id'))==post_id_str :
+ post_data_found =post_in_list
+ break
+
+ if post_data_found :
+
+ creator_info_original ,_unused_posts =self .fetched_posts_data .get (creator_key_for_fetched_data ,({},[]))
+ creator_name =creator_info_original .get ('name','Unknown Creator')if creator_info_original else 'Unknown Creator'
+
+ domain =self ._get_domain_for_service (service )
+ post_url =f"https://{domain }/{service }/user/{user_id_str }/post/{post_id_str }"
+ queue_item ={
+ 'type':'single_post_from_popup',
+ 'url':post_url ,
+ 'name':post_data_found .get ('title',self ._tr ('untitled_post_placeholder','Untitled Post')),
+ 'name_for_folder':creator_name ,
+ 'service':service ,
+ 'user_id':user_id_str ,
+ 'post_id':post_id_str
+ }
+ selected_posts_for_queue .append (queue_item )
+ else :
+
+
+
+
+ if self .parent_app and hasattr (self .parent_app ,'log_signal'):
+ self .parent_app .log_signal .emit (f"⚠️ Could not find full post data for selected key: {post_key } when adding to queue.")
+
+ else :
+ domain =self ._get_domain_for_service (service )
+ post_url =f"https://{domain }/{service }/user/{user_id_str }/post/{post_id_str }"
+ queue_item ={
+ 'type':'single_post_from_popup',
+ 'url':post_url ,
+ 'name':f"post id {post_id_str }",
+ 'name_for_folder':user_id_str ,
+ 'service':service ,
+ 'user_id':user_id_str ,
+ 'post_id':post_id_str
+ }
+ selected_posts_for_queue .append (queue_item )
+
+ if selected_posts_for_queue :
+ if self .parent_app and hasattr (self .parent_app ,'favorite_download_queue'):
+ for qi in selected_posts_for_queue :
+ self .parent_app .favorite_download_queue .append (qi )
+
+ num_just_added_posts =len (selected_posts_for_queue )
+ total_in_queue =len (self .parent_app .favorite_download_queue )
+
+ self .parent_app .log_signal .emit (f"ℹ️ Added {num_just_added_posts } selected posts to the download queue. Total in queue: {total_in_queue }.")
+
+ if self .parent_app .link_input :
+ self .parent_app .link_input .blockSignals (True )
+ self .parent_app .link_input .setText (
+ self .parent_app ._tr ("popup_posts_selected_text","Posts - {count} selected").format (count =num_just_added_posts )
+ )
+ self .parent_app .link_input .blockSignals (False )
+ self .parent_app .link_input .setPlaceholderText (
+ self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue )
+ )
+ self .accept ()
+ else :
+ QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
+ self ._tr ("select_posts_to_queue_message","Please select at least one post to add to the queue."))
+
+ def _handle_posts_close_view (self ):
+ self .right_pane_widget .hide ()
+ self .main_splitter .setSizes ([self .width (),0 ])
+ self .posts_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
+ if hasattr (self ,'_handle_post_item_check_changed'):
+ self .posts_title_list_widget .itemChanged .disconnect (self ._handle_post_item_check_changed )
+ self .posts_search_input .setVisible (False )
+ self .posts_search_input .clear ()
+ self .globally_selected_post_ids .clear ()
+ self .add_selected_button .setEnabled (True )
+ self .setWindowTitle (self ._tr ("creator_popup_title","Creator Selection"))
+
+
+
+
+ def _get_domain_for_service (self ,service_name ):
+ """Determines the base domain for a given service."""
+ service_lower =service_name .lower ()
+ if service_lower in ['onlyfans','fansly']:
+ return "coomer.su"
+ return "kemono.su"
+
+ def _handle_add_selected (self ):
+ """Gathers globally selected creators and processes them."""
+ selected_display_names =[]
+ self .selected_creators_for_queue .clear ()
+ for creator_data in self .globally_selected_creators .values ():
+ creator_name =creator_data .get ('name')
+ self .selected_creators_for_queue .append (creator_data )
+ if creator_name :
+ selected_display_names .append (creator_name )
+
+ if selected_display_names :
+ main_app_window =self .parent ()
+ if hasattr (main_app_window ,'link_input'):
+ main_app_window .link_input .setText (", ".join (selected_display_names ))
+ self .accept ()
+ else :
+ QMessageBox .information (self ,"No Selection","No creators selected to add.")
+
+ def _handle_item_check_changed (self ,item ):
+ """Updates the globally_selected_creators dict when an item's check state changes."""
+ creator_data =item .data (Qt .UserRole )
+ if not isinstance (creator_data ,dict ):
+ return
+
+ service =creator_data .get ('service')
+ creator_id =creator_data .get ('id')
+
+ if service is None or creator_id is None :
+ print (f"Warning: Creator data in list item missing service or id: {creator_data .get ('name')}")
+ return
+
+ unique_key =(str (service ),str (creator_id ))
+
+ if item .checkState ()==Qt .Checked :
+ self .globally_selected_creators [unique_key ]=creator_data
+ 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 ))
\ No newline at end of file
diff --git a/src/ui/dialogs/ErrorFilesDialog.py b/src/ui/dialogs/ErrorFilesDialog.py
new file mode 100644
index 0000000..7e9baa7
--- /dev/null
+++ b/src/ui/dialogs/ErrorFilesDialog.py
@@ -0,0 +1,230 @@
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import pyqtSignal, Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
+ QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QFileDialog
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..assets import get_app_icon_object
+# Corrected Import: The filename uses PascalCase.
+from .ExportOptionsDialog import ExportOptionsDialog
+
+
+class ErrorFilesDialog(QDialog):
+ """
+ Dialog to display files that were skipped due to errors and
+ allows the user to retry downloading them or export the list of URLs.
+ """
+
+ # Signal emitted with a list of file info dictionaries to retry
+ retry_selected_signal = pyqtSignal(list)
+
+ def __init__(self, error_files_info_list, parent_app, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ error_files_info_list (list): A list of dictionaries, each containing
+ info about a failed file.
+ parent_app (DownloaderApp): A reference to the main application window
+ for theming and translations.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app
+ self.setModal(True)
+ self.error_files = error_files_info_list
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically
+ screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
+ scale_factor = screen_height / 1080.0
+ base_min_w, base_min_h = 500, 300
+ 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)
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ main_layout = QVBoxLayout(self)
+
+ self.info_label = QLabel()
+ self.info_label.setWordWrap(True)
+ main_layout.addWidget(self.info_label)
+
+ if self.error_files:
+ self.files_list_widget = QListWidget()
+ self.files_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
+ self._populate_list()
+ main_layout.addWidget(self.files_list_widget)
+
+ # --- Control Buttons ---
+ buttons_layout = QHBoxLayout()
+ self.select_all_button = QPushButton()
+ self.select_all_button.clicked.connect(self._select_all_items)
+ buttons_layout.addWidget(self.select_all_button)
+
+ self.retry_button = QPushButton()
+ self.retry_button.clicked.connect(self._handle_retry_selected)
+ buttons_layout.addWidget(self.retry_button)
+
+ self.export_button = QPushButton()
+ self.export_button.clicked.connect(self._handle_export_errors_to_txt)
+ buttons_layout.addWidget(self.export_button)
+ buttons_layout.addStretch(1)
+
+ self.ok_button = QPushButton()
+ self.ok_button.clicked.connect(self.accept)
+ self.ok_button.setDefault(True)
+ buttons_layout.addWidget(self.ok_button)
+ main_layout.addLayout(buttons_layout)
+
+ # Enable/disable buttons based on whether there are errors
+ has_errors = bool(self.error_files)
+ self.select_all_button.setEnabled(has_errors)
+ self.retry_button.setEnabled(has_errors)
+ self.export_button.setEnabled(has_errors)
+
+ def _populate_list(self):
+ """Populates the list widget with details of the failed files."""
+ for error_info in self.error_files:
+ filename = error_info.get('forced_filename_override',
+ error_info.get('file_info', {}).get('name', 'Unknown Filename'))
+ post_title = error_info.get('post_title', 'Unknown Post')
+ post_id = error_info.get('original_post_id_for_log', 'N/A')
+
+ item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})"
+ list_item = QListWidgetItem(item_text)
+ list_item.setData(Qt.UserRole, error_info)
+ list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
+ list_item.setCheckState(Qt.Unchecked)
+ self.files_list_widget.addItem(list_item)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("error_files_dialog_title", "Files Skipped Due to Errors"))
+ if not self.error_files:
+ self.info_label.setText(self._tr("error_files_no_errors_label", "No files were recorded as skipped..."))
+ else:
+ self.info_label.setText(self._tr("error_files_found_label", "The following {count} file(s)...").format(count=len(self.error_files)))
+
+ self.select_all_button.setText(self._tr("error_files_select_all_button", "Select All"))
+ self.retry_button.setText(self._tr("error_files_retry_selected_button", "Retry Selected"))
+ self.export_button.setText(self._tr("error_files_export_urls_button", "Export URLs to .txt"))
+ self.ok_button.setText(self._tr("ok_button", "OK"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
+ if hasattr(self.parent_app, 'get_dark_theme'):
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ def _select_all_items(self):
+ """Checks all items in the list."""
+ if hasattr(self, 'files_list_widget'):
+ for i in range(self.files_list_widget.count()):
+ self.files_list_widget.item(i).setCheckState(Qt.Checked)
+
+ def _handle_retry_selected(self):
+ """Gathers selected files and emits the retry signal."""
+ if not hasattr(self, 'files_list_widget'):
+ return
+
+ selected_files_for_retry = [
+ self.files_list_widget.item(i).data(Qt.UserRole)
+ for i in range(self.files_list_widget.count())
+ if self.files_list_widget.item(i).checkState() == Qt.Checked
+ ]
+
+ if selected_files_for_retry:
+ self.retry_selected_signal.emit(selected_files_for_retry)
+ self.accept()
+ else:
+ QMessageBox.information(
+ self,
+ self._tr("fav_artists_no_selection_title", "No Selection"),
+ self._tr("error_files_no_selection_retry_message", "Please select at least one file to retry.")
+ )
+
+ def _handle_export_errors_to_txt(self):
+ """Exports the URLs of failed files to a text file."""
+ if not self.error_files:
+ QMessageBox.information(
+ self,
+ self._tr("error_files_no_errors_export_title", "No Errors"),
+ self._tr("error_files_no_errors_export_message", "There are no error file URLs to export.")
+ )
+ return
+
+ options_dialog = ExportOptionsDialog(parent_app=self.parent_app, parent=self)
+ if not options_dialog.exec_() == QDialog.Accepted:
+ return
+
+ export_option = options_dialog.get_selected_option()
+
+ lines_to_export = []
+ for error_item in self.error_files:
+ file_info = error_item.get('file_info', {})
+ url = file_info.get('url')
+
+ if url:
+ if export_option == ExportOptionsDialog.EXPORT_MODE_WITH_DETAILS:
+ original_filename = file_info.get('name', 'Unknown Filename')
+ post_title = error_item.get('post_title', 'Unknown Post')
+ post_id = error_item.get('original_post_id_for_log', 'N/A')
+ details_string = f" [Post: '{post_title}' (ID: {post_id}), File: '{original_filename}']"
+ lines_to_export.append(f"{url}{details_string}")
+ else:
+ lines_to_export.append(url)
+
+ if not lines_to_export:
+ QMessageBox.information(
+ self,
+ self._tr("error_files_no_urls_found_export_title", "No URLs Found"),
+ self._tr("error_files_no_urls_found_export_message", "Could not extract any URLs...")
+ )
+ return
+
+ default_filename = "error_file_links.txt"
+ filepath, _ = QFileDialog.getSaveFileName(
+ self,
+ self._tr("error_files_save_dialog_title", "Save Error File URLs"),
+ default_filename,
+ "Text Files (*.txt);;All Files (*)"
+ )
+
+ if filepath:
+ try:
+ with open(filepath, 'w', encoding='utf-8') as f:
+ for line in lines_to_export:
+ f.write(f"{line}\n")
+ QMessageBox.information(
+ self,
+ self._tr("error_files_export_success_title", "Export Successful"),
+ self._tr("error_files_export_success_message", "Successfully exported...").format(
+ count=len(lines_to_export), filepath=filepath
+ )
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ self._tr("error_files_export_error_title", "Export Error"),
+ self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
+ )
diff --git a/src/ui/dialogs/ExportOptionsDialog.py b/src/ui/dialogs/ExportOptionsDialog.py
new file mode 100644
index 0000000..5723bcc
--- /dev/null
+++ b/src/ui/dialogs/ExportOptionsDialog.py
@@ -0,0 +1,118 @@
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
+ QRadioButton, QButtonGroup
+)
+
+# --- Local Application Imports ---
+# This assumes the new project structure is in place.
+from ...i18n.translator import get_translation
+# get_app_icon_object is defined in the main window module in this refactoring plan.
+from ..main_window import get_app_icon_object
+
+
+class ExportOptionsDialog(QDialog):
+ """
+ Dialog to choose the export format for error file links.
+ It allows the user to select between exporting only the URLs or
+ exporting URLs with additional details.
+ """
+ # Constants to define the export modes
+ EXPORT_MODE_LINK_ONLY = 1
+ EXPORT_MODE_WITH_DETAILS = 2
+
+ def __init__(self, parent_app, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ parent_app (DownloaderApp): A reference to the main application window for theming and translations.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app
+ self.setModal(True)
+ # Default option
+ self.selected_option = self.EXPORT_MODE_LINK_ONLY
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically
+ screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
+ scale_factor = screen_height / 768.0
+ base_min_w = 350
+ scaled_min_w = int(base_min_w * scale_factor)
+ self.setMinimumWidth(scaled_min_w)
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ layout = QVBoxLayout(self)
+
+ self.description_label = QLabel()
+ layout.addWidget(self.description_label)
+
+ self.radio_group = QButtonGroup(self)
+
+ self.radio_link_only = QRadioButton()
+ self.radio_link_only.setChecked(True)
+ self.radio_group.addButton(self.radio_link_only, self.EXPORT_MODE_LINK_ONLY)
+ layout.addWidget(self.radio_link_only)
+
+ self.radio_with_details = QRadioButton()
+ self.radio_group.addButton(self.radio_with_details, self.EXPORT_MODE_WITH_DETAILS)
+ layout.addWidget(self.radio_with_details)
+
+ # --- Action Buttons ---
+ button_layout = QHBoxLayout()
+ self.export_button = QPushButton()
+ self.export_button.clicked.connect(self._handle_export)
+ self.export_button.setDefault(True)
+
+ self.cancel_button = QPushButton()
+ self.cancel_button.clicked.connect(self.reject)
+
+ button_layout.addStretch(1)
+ button_layout.addWidget(self.export_button)
+ button_layout.addWidget(self.cancel_button)
+ layout.addLayout(button_layout)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("export_options_dialog_title", "Export Options"))
+ self.description_label.setText(self._tr("export_options_description_label", "Choose the format for exporting error file links:"))
+ self.radio_link_only.setText(self._tr("export_options_radio_link_only", "Link per line (URL only)"))
+ self.radio_link_only.setToolTip(self._tr("export_options_radio_link_only_tooltip", "Exports only the direct download URL..."))
+ self.radio_with_details.setText(self._tr("export_options_radio_with_details", "Export with details (URL [Post, File info])"))
+ self.radio_with_details.setToolTip(self._tr("export_options_radio_with_details_tooltip", "Exports the URL followed by details..."))
+ self.export_button.setText(self._tr("export_options_export_button", "Export"))
+ self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
+ if hasattr(self.parent_app, 'get_dark_theme'):
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ def _handle_export(self):
+ """Sets the selected export option and accepts the dialog."""
+ self.selected_option = self.radio_group.checkedId()
+ self.accept()
+
+ def get_selected_option(self):
+ """Returns the export mode chosen by the user."""
+ return self.selected_option
diff --git a/src/ui/dialogs/FavoriteArtistsDialog.py b/src/ui/dialogs/FavoriteArtistsDialog.py
new file mode 100644
index 0000000..c50211c
--- /dev/null
+++ b/src/ui/dialogs/FavoriteArtistsDialog.py
@@ -0,0 +1,288 @@
+# --- Standard Library Imports ---
+import html
+import re
+
+# --- Third-Party Library Imports ---
+import requests
+from PyQt5.QtCore import QCoreApplication, Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
+ QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+# Corrected Import: Get the icon from the new assets utility module
+from ..assets import get_app_icon_object
+from ...utils.network_utils import prepare_cookies_for_request
+from .CookieHelpDialog import CookieHelpDialog
+
+
+class FavoriteArtistsDialog (QDialog ):
+ """Dialog to display and select favorite artists."""
+ def __init__ (self ,parent_app ,cookies_config ):
+ super ().__init__ (parent_app )
+ self .parent_app =parent_app
+ self .cookies_config =cookies_config
+ self .all_fetched_artists =[]
+
+ app_icon =get_app_icon_object ()
+ if not app_icon .isNull ():
+ self .setWindowIcon (app_icon )
+ self .selected_artist_urls =[]
+
+ self .setModal (True )
+ self .setMinimumSize (500 ,500 )
+
+ self ._init_ui ()
+ self ._fetch_favorite_artists ()
+
+ def _get_domain_for_service (self ,service_name ):
+ service_lower =service_name .lower ()
+ coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'}
+ if service_lower in coomer_primary_services :
+ return "coomer.su"
+ else :
+ return "kemono.su"
+
+ def _tr (self ,key ,default_text =""):
+ """Helper to get translation based on current app language."""
+ if callable (get_translation )and self .parent_app :
+ return get_translation (self .parent_app .current_selected_language ,key ,default_text )
+ return default_text
+
+ def _retranslate_ui (self ):
+ self .setWindowTitle (self ._tr ("fav_artists_dialog_title","Favorite Artists"))
+ self .status_label .setText (self ._tr ("fav_artists_loading_status","Loading favorite artists..."))
+ self .search_input .setPlaceholderText (self ._tr ("fav_artists_search_placeholder","Search artists..."))
+ self .select_all_button .setText (self ._tr ("fav_artists_select_all_button","Select All"))
+ self .deselect_all_button .setText (self ._tr ("fav_artists_deselect_all_button","Deselect All"))
+ self .download_button .setText (self ._tr ("fav_artists_download_selected_button","Download Selected"))
+ self .cancel_button .setText (self ._tr ("fav_artists_cancel_button","Cancel"))
+
+ def _init_ui (self ):
+ main_layout =QVBoxLayout (self )
+
+ self .status_label =QLabel ()
+ self .status_label .setAlignment (Qt .AlignCenter )
+ main_layout .addWidget (self .status_label )
+
+ self .search_input =QLineEdit ()
+ self .search_input .textChanged .connect (self ._filter_artist_list_display )
+ main_layout .addWidget (self .search_input )
+
+
+ self .artist_list_widget =QListWidget ()
+ self .artist_list_widget .setStyleSheet ("""
+ QListWidget::item {
+ border-bottom: 1px solid #4A4A4A; /* Slightly softer line */
+ padding-top: 4px;
+ padding-bottom: 4px;
+ }""")
+ main_layout .addWidget (self .artist_list_widget )
+ self .artist_list_widget .setAlternatingRowColors (True )
+ self .search_input .setVisible (False )
+ self .artist_list_widget .setVisible (False )
+
+ combined_buttons_layout =QHBoxLayout ()
+
+ self .select_all_button =QPushButton ()
+ self .select_all_button .clicked .connect (self ._select_all_items )
+ combined_buttons_layout .addWidget (self .select_all_button )
+
+ self .deselect_all_button =QPushButton ()
+ self .deselect_all_button .clicked .connect (self ._deselect_all_items )
+ combined_buttons_layout .addWidget (self .deselect_all_button )
+
+
+ self .download_button =QPushButton ()
+ self .download_button .clicked .connect (self ._accept_selection_action )
+ self .download_button .setEnabled (False )
+ self .download_button .setDefault (True )
+ combined_buttons_layout .addWidget (self .download_button )
+
+ self .cancel_button =QPushButton ()
+ self .cancel_button .clicked .connect (self .reject )
+ combined_buttons_layout .addWidget (self .cancel_button )
+
+ combined_buttons_layout .addStretch (1 )
+ main_layout .addLayout (combined_buttons_layout )
+
+ self ._retranslate_ui ()
+ if hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
+ self .setStyleSheet (self .parent_app .get_dark_theme ())
+
+
+ def _logger (self ,message ):
+ """Helper to log messages, either to parent app or console."""
+ if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
+ self .parent_app .log_signal .emit (f"[FavArtistsDialog] {message }")
+ else :
+ print (f"[FavArtistsDialog] {message }")
+
+ def _show_content_elements (self ,show ):
+ """Helper to show/hide content-related widgets."""
+ self .search_input .setVisible (show )
+ self .artist_list_widget .setVisible (show )
+
+ def _fetch_favorite_artists (self ):
+ kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
+ coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
+
+ self .all_fetched_artists =[]
+ fetched_any_successfully =False
+ errors_occurred =[]
+ any_cookies_loaded_successfully_for_any_source =False
+
+ api_sources =[
+ {"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"},
+ {"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
+ ]
+
+ for source in api_sources :
+ self ._logger (f"Attempting to fetch favorite artists from: {source ['name']} ({source ['url']})")
+ self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
+ QCoreApplication .processEvents ()
+
+ cookies_dict_for_source =None
+ if self .cookies_config ['use_cookie']:
+ cookies_dict_for_source =prepare_cookies_for_request (
+ True ,
+ self .cookies_config ['cookie_text'],
+ self .cookies_config ['selected_cookie_file'],
+ self .cookies_config ['app_base_dir'],
+ self ._logger ,
+ target_domain =source ['domain']
+ )
+ if cookies_dict_for_source :
+ any_cookies_loaded_successfully_for_any_source =True
+ else :
+ self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
+ try :
+ headers ={'User-Agent':'Mozilla/5.0'}
+ response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
+ response .raise_for_status ()
+ artists_data_from_api =response .json ()
+
+ if not isinstance (artists_data_from_api ,list ):
+ error_msg =f"Error ({source ['name']}): API did not return a list of artists (got {type (artists_data_from_api )})."
+ self ._logger (error_msg )
+ errors_occurred .append (error_msg )
+ continue
+
+ processed_artists_from_source =0
+ for artist_entry in artists_data_from_api :
+ artist_id =artist_entry .get ("id")
+ artist_name =html .unescape (artist_entry .get ("name","Unknown Artist").strip ())
+ artist_service_platform =artist_entry .get ("service")
+
+ if artist_id and artist_name and artist_service_platform :
+ artist_page_domain =self ._get_domain_for_service (artist_service_platform )
+ full_url =f"https://{artist_page_domain }/{artist_service_platform }/user/{artist_id }"
+
+ self .all_fetched_artists .append ({
+ 'name':artist_name ,
+ 'url':full_url ,
+ 'service':artist_service_platform ,
+ 'id':artist_id ,
+ '_source_api':source ['name']
+ })
+ processed_artists_from_source +=1
+ else :
+ self ._logger (f"Warning ({source ['name']}): Skipping favorite artist entry due to missing data: {artist_entry }")
+
+ if processed_artists_from_source >0 :
+ fetched_any_successfully =True
+ self ._logger (f"Fetched {processed_artists_from_source } artists from {source ['name']}.")
+
+ except requests .exceptions .RequestException as e :
+ error_msg =f"Error fetching favorites from {source ['name']}: {e }"
+ self ._logger (error_msg )
+ errors_occurred .append (error_msg )
+ except Exception as e :
+ error_msg =f"An unexpected error occurred with {source ['name']}: {e }"
+ self ._logger (error_msg )
+ errors_occurred .append (error_msg )
+
+
+ if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
+ self .status_label .setText (self ._tr ("fav_artists_cookies_required_status","Error: Cookies enabled but could not be loaded for any source."))
+ self ._logger ("Error: Cookies enabled but no cookies loaded for any source. Showing help dialog.")
+ cookie_help_dialog =CookieHelpDialog (self )
+ cookie_help_dialog .exec_ ()
+ self .download_button .setEnabled (False )
+ if not fetched_any_successfully :
+ errors_occurred .append ("Cookies enabled but could not be loaded for any API source.")
+
+ unique_artists_map ={}
+ for artist in self .all_fetched_artists :
+ key =(artist ['service'].lower (),str (artist ['id']).lower ())
+ if key not in unique_artists_map :
+ unique_artists_map [key ]=artist
+ self .all_fetched_artists =list (unique_artists_map .values ())
+
+ self .all_fetched_artists .sort (key =lambda x :x ['name'].lower ())
+ self ._populate_artist_list_widget ()
+
+ if fetched_any_successfully and self .all_fetched_artists :
+ self .status_label .setText (self ._tr ("fav_artists_found_status","Found {count} total favorite artist(s).").format (count =len (self .all_fetched_artists )))
+ self ._show_content_elements (True )
+ self .download_button .setEnabled (True )
+ elif not fetched_any_successfully and not errors_occurred :
+ self .status_label .setText (self ._tr ("fav_artists_none_found_status","No favorite artists found on Kemono.su or Coomer.su."))
+ self ._show_content_elements (False )
+ self .download_button .setEnabled (False )
+ else :
+ final_error_message =self ._tr ("fav_artists_failed_status","Failed to fetch favorites.")
+ if errors_occurred :
+ final_error_message +=" Errors: "+"; ".join (errors_occurred )
+ self .status_label .setText (final_error_message )
+ self ._show_content_elements (False )
+ self .download_button .setEnabled (False )
+ if fetched_any_successfully and not self .all_fetched_artists :
+ self .status_label .setText (self ._tr ("fav_artists_no_favorites_after_processing","No favorite artists found after processing."))
+
+ def _populate_artist_list_widget (self ,artists_to_display =None ):
+ self .artist_list_widget .clear ()
+ source_list =artists_to_display if artists_to_display is not None else self .all_fetched_artists
+ for artist_data in source_list :
+ item =QListWidgetItem (f"{artist_data ['name']} ({artist_data .get ('service','N/A').capitalize ()})")
+ item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
+ item .setCheckState (Qt .Unchecked )
+ item .setData (Qt .UserRole ,artist_data )
+ self .artist_list_widget .addItem (item )
+
+ def _filter_artist_list_display (self ):
+ search_text =self .search_input .text ().lower ().strip ()
+ if not search_text :
+ self ._populate_artist_list_widget ()
+ return
+
+ filtered_artists =[
+ artist for artist in self .all_fetched_artists
+ if search_text in artist ['name'].lower ()or search_text in artist ['url'].lower ()
+ ]
+ self ._populate_artist_list_widget (filtered_artists )
+
+ def _select_all_items (self ):
+ for i in range (self .artist_list_widget .count ()):
+ self .artist_list_widget .item (i ).setCheckState (Qt .Checked )
+
+ def _deselect_all_items (self ):
+ for i in range (self .artist_list_widget .count ()):
+ self .artist_list_widget .item (i ).setCheckState (Qt .Unchecked )
+
+ def _accept_selection_action (self ):
+ self .selected_artists_data =[]
+ for i in range (self .artist_list_widget .count ()):
+ item =self .artist_list_widget .item (i )
+ if item .checkState ()==Qt .Checked :
+ self .selected_artists_data .append (item .data (Qt .UserRole ))
+
+ if not self .selected_artists_data :
+ QMessageBox .information (self ,"No Selection","Please select at least one artist to download.")
+ return
+ self .accept ()
+
+ def get_selected_artists (self ):
+ return self .selected_artists_data
\ No newline at end of file
diff --git a/src/ui/dialogs/FavoritePostsDialog.py b/src/ui/dialogs/FavoritePostsDialog.py
new file mode 100644
index 0000000..3b94078
--- /dev/null
+++ b/src/ui/dialogs/FavoritePostsDialog.py
@@ -0,0 +1,629 @@
+# --- Standard Library Imports ---
+import html
+import os
+import sys
+import threading
+import time
+import traceback
+import json
+import re
+from collections import defaultdict
+
+# --- Third-Party Library Imports ---
+import requests
+from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
+ QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
+ QWidget, QCheckBox
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..assets import get_app_icon_object
+from ...utils.network_utils import prepare_cookies_for_request
+# Corrected Import: Import CookieHelpDialog directly from its own module
+from .CookieHelpDialog import CookieHelpDialog
+from ...core.api_client import download_from_api
+
+
+class FavoritePostsFetcherThread (QThread ):
+ """Worker thread to fetch favorite posts and creator names."""
+ status_update =pyqtSignal (str )
+ progress_bar_update =pyqtSignal (int ,int )
+ finished =pyqtSignal (list ,str )
+
+ def __init__ (self ,cookies_config ,parent_logger_func ,target_domain_preference =None ):
+ super ().__init__ ()
+ self .cookies_config =cookies_config
+ self .parent_logger_func =parent_logger_func
+ self .target_domain_preference =target_domain_preference
+ self .cancellation_event =threading .Event ()
+ self .error_key_map ={
+ "Kemono.su":"kemono_su",
+ "Coomer.su":"coomer_su"
+ }
+
+ def _logger (self ,message ):
+ self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
+
+ def run (self ):
+ kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post"
+ coomer_fav_posts_url ="https://coomer.su/api/v1/account/favorites?type=post"
+
+ all_fetched_posts_temp =[]
+ error_messages_for_summary =[]
+ fetched_any_successfully =False
+ any_cookies_loaded_successfully_for_any_source =False
+
+ self .status_update .emit ("key_fetching_fav_post_list_init")
+ self .progress_bar_update .emit (0 ,0 )
+
+ api_sources =[
+ {"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"},
+ {"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"}
+ ]
+
+ api_sources_to_try =[]
+ if self .target_domain_preference :
+ self ._logger (f"Targeting specific domain for favorites: {self .target_domain_preference }")
+ for source_def in api_sources :
+ if source_def ["domain"]==self .target_domain_preference :
+ api_sources_to_try .append (source_def )
+ break
+ if not api_sources_to_try :
+ self ._logger (f"Warning: Preferred domain '{self .target_domain_preference }' not a recognized API source. Fetching from all.")
+ api_sources_to_try =api_sources
+ else :
+ self ._logger ("No specific domain preference, or both domains have cookies. Will attempt to fetch from all sources.")
+ api_sources_to_try =api_sources
+
+ for source in api_sources_to_try :
+ if self .cancellation_event .is_set ():
+ self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
+ return
+ cookies_dict_for_source =None
+ if self .cookies_config ['use_cookie']:
+ cookies_dict_for_source =prepare_cookies_for_request (
+ True ,
+ self .cookies_config ['cookie_text'],
+ self .cookies_config ['selected_cookie_file'],
+ self .cookies_config ['app_base_dir'],
+ self ._logger ,
+ target_domain =source ['domain']
+ )
+ if cookies_dict_for_source :
+ any_cookies_loaded_successfully_for_any_source =True
+ else :
+ self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
+
+ self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})")
+ source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
+ self .status_update .emit (f"key_fetching_from_source_{source_key_part }")
+ QCoreApplication .processEvents ()
+
+ try :
+ headers ={'User-Agent':'Mozilla/5.0'}
+ response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
+ response .raise_for_status ()
+ posts_data_from_api =response .json ()
+
+ if not isinstance (posts_data_from_api ,list ):
+ err_detail =f"Error ({source ['name']}): API did not return a list of posts (got {type (posts_data_from_api )})."
+ self ._logger (err_detail )
+ error_messages_for_summary .append (err_detail )
+ continue
+
+ processed_posts_from_source =0
+ for post_entry in posts_data_from_api :
+ post_id =post_entry .get ("id")
+ post_title =html .unescape (post_entry .get ("title","Untitled Post").strip ())
+ service =post_entry .get ("service")
+ creator_id =post_entry .get ("user")
+ added_date_str =post_entry .get ("added",post_entry .get ("published",""))
+
+ if post_id and post_title and service and creator_id :
+ all_fetched_posts_temp .append ({
+ 'post_id':post_id ,'title':post_title ,'service':service ,
+ 'creator_id':creator_id ,'added_date':added_date_str ,
+ '_source_api':source ['name']
+ })
+ processed_posts_from_source +=1
+ else :
+ self ._logger (f"Warning ({source ['name']}): Skipping favorite post entry due to missing data: {post_entry }")
+
+ if processed_posts_from_source >0 :
+ fetched_any_successfully =True
+ self ._logger (f"Fetched {processed_posts_from_source } posts from {source ['name']}.")
+
+ except requests .exceptions .RequestException as e :
+ err_detail =f"Error fetching favorite posts from {source ['name']}: {e }"
+ self ._logger (err_detail )
+ error_messages_for_summary .append (err_detail )
+ if e .response is not None and e .response .status_code ==401 :
+ self .finished .emit ([],"KEY_AUTH_FAILED")
+ self ._logger (f"Authorization failed for {source ['name']}, emitting KEY_AUTH_FAILED.")
+ return
+ except Exception as e :
+ err_detail =f"An unexpected error occurred with {source ['name']}: {e }"
+ self ._logger (err_detail )
+ error_messages_for_summary .append (err_detail )
+
+ if self .cancellation_event .is_set ():
+ self .finished .emit ([],"KEY_FETCH_CANCELLED_AFTER")
+ return
+
+
+ if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
+
+ if self .target_domain_preference and not any_cookies_loaded_successfully_for_any_source :
+
+ domain_key_part =self .error_key_map .get (self .target_domain_preference ,self .target_domain_preference .lower ().replace ('.','_'))
+ self .finished .emit ([],f"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_{domain_key_part }")
+ return
+
+
+ self .finished .emit ([],"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC")
+ return
+
+ unique_posts_map ={}
+ for post in all_fetched_posts_temp :
+ key =(post ['service'].lower (),str (post ['creator_id']).lower (),str (post ['post_id']).lower ())
+ if key not in unique_posts_map :
+ unique_posts_map [key ]=post
+ all_fetched_posts_temp =list (unique_posts_map .values ())
+
+ all_fetched_posts_temp .sort (key =lambda x :(x .get ('_source_api','').lower (),x .get ('service','').lower (),str (x .get ('creator_id','')).lower (),(x .get ('added_date')or '')),reverse =False )
+
+ if error_messages_for_summary :
+ error_summary_str ="; ".join (error_messages_for_summary )
+ if not fetched_any_successfully :
+ self .finished .emit ([],f"KEY_FETCH_FAILED_GENERIC_{error_summary_str [:50 ]}")
+ else :
+ self .finished .emit (all_fetched_posts_temp ,f"KEY_FETCH_PARTIAL_SUCCESS_{error_summary_str [:50 ]}")
+ elif not all_fetched_posts_temp and not fetched_any_successfully and not self .target_domain_preference :
+ self .finished .emit ([],"KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS")
+ else :
+ self .finished .emit (all_fetched_posts_temp ,"KEY_FETCH_SUCCESS")
+
+class PostListItemWidget (QWidget ):
+ """Custom widget for displaying a single post in the FavoritePostsDialog list."""
+ def __init__ (self ,post_data_dict ,parent_dialog_ref ,parent =None ):
+ super ().__init__ (parent )
+ self .post_data =post_data_dict
+ self .parent_dialog =parent_dialog_ref
+
+ self .layout =QHBoxLayout (self )
+ self .layout .setContentsMargins (5 ,3 ,5 ,3 )
+ self .layout .setSpacing (10 )
+
+ self .checkbox =QCheckBox ()
+ self .layout .addWidget (self .checkbox )
+
+ self .info_label =QLabel ()
+ self .info_label .setWordWrap (True )
+ self .info_label .setTextFormat (Qt .RichText )
+ self .layout .addWidget (self .info_label ,1 )
+
+ self ._setup_display_text ()
+ def _setup_display_text (self ):
+ suffix_plain =self .post_data .get ('suffix_for_display',"")
+ title_plain =self .post_data .get ('title','Untitled Post')
+ escaped_suffix =html .escape (suffix_plain )
+ escaped_title =html .escape (title_plain )
+ p_style_paragraph ="font-size:10.5pt; margin:0; padding:0;"
+ title_span_style ="font-weight:bold; color:#E0E0E0;"
+ suffix_span_style ="color:#999999; font-weight:normal; font-size:9.5pt;"
+
+ if escaped_suffix :
+ display_html_content =f"{escaped_title }{escaped_suffix }
"
+ else :
+ display_html_content =f"{escaped_title }
"
+
+ self .info_label .setText (display_html_content )
+
+ def isChecked (self ):return self .checkbox .isChecked ()
+ def setCheckState (self ,state ):self .checkbox .setCheckState (state )
+ def get_post_data (self ):return self .post_data
+
+class FavoritePostsDialog (QDialog ):
+ """Dialog to display and select favorite posts."""
+ def __init__ (self ,parent_app ,cookies_config ,known_names_list_ref ,target_domain_preference =None ):
+ super ().__init__ (parent_app )
+ self .parent_app =parent_app
+ self .cookies_config =cookies_config
+ self .all_fetched_posts =[]
+ self .selected_posts_data =[]
+ self .known_names_list_ref =known_names_list_ref
+ self .target_domain_preference_for_this_fetch =target_domain_preference
+ self .creator_name_cache ={}
+ self .displayable_grouped_posts ={}
+ self .fetcher_thread =None
+
+ app_icon =get_app_icon_object ()
+ if not app_icon .isNull ():
+ self .setWindowIcon (app_icon )
+
+ self .setModal (True )
+ self .setMinimumSize (600 ,600 )
+ if hasattr (self .parent_app ,'get_dark_theme'):
+ self .setStyleSheet (self .parent_app .get_dark_theme ())
+
+ self ._init_ui ()
+ self ._load_creator_names_from_file ()
+ self ._retranslate_ui ()
+ self ._start_fetching_favorite_posts ()
+
+ def _update_status_label_from_key (self ,status_key ):
+ """Translates a status key and updates the status label."""
+
+ translated_status =self ._tr (status_key .lower (),status_key )
+ self .status_label .setText (translated_status )
+
+ def _init_ui (self ):
+ main_layout =QVBoxLayout (self )
+
+ self .status_label =QLabel ()
+ self .status_label .setAlignment (Qt .AlignCenter )
+ main_layout .addWidget (self .status_label )
+
+ self .progress_bar =QProgressBar ()
+ self .progress_bar .setTextVisible (False )
+ self .progress_bar .setVisible (False )
+ main_layout .addWidget (self .progress_bar )
+
+ self .search_input =QLineEdit ()
+
+ self .search_input .textChanged .connect (self ._filter_post_list_display )
+ main_layout .addWidget (self .search_input )
+
+ self .post_list_widget =QListWidget ()
+ self .post_list_widget .setStyleSheet ("""
+ QListWidget::item {
+ border-bottom: 1px solid #4A4A4A;
+ padding-top: 4px;
+ padding-bottom: 4px;
+ }""")
+ self .post_list_widget .setAlternatingRowColors (True )
+ main_layout .addWidget (self .post_list_widget )
+
+ combined_buttons_layout =QHBoxLayout ()
+ self .select_all_button =QPushButton ()
+ self .select_all_button .clicked .connect (self ._select_all_items )
+ combined_buttons_layout .addWidget (self .select_all_button )
+
+ self .deselect_all_button =QPushButton ()
+ self .deselect_all_button .clicked .connect (self ._deselect_all_items )
+ combined_buttons_layout .addWidget (self .deselect_all_button )
+
+ self .download_button =QPushButton ()
+ self .download_button .clicked .connect (self ._accept_selection_action )
+ self .download_button .setEnabled (False )
+ self .download_button .setDefault (True )
+ combined_buttons_layout .addWidget (self .download_button )
+
+ self .cancel_button =QPushButton ()
+ self .cancel_button .clicked .connect (self .reject )
+ combined_buttons_layout .addWidget (self .cancel_button )
+ combined_buttons_layout .addStretch (1 )
+ main_layout .addLayout (combined_buttons_layout )
+
+ def _tr (self ,key ,default_text =""):
+ """Helper to get translation based on current app language."""
+ if callable (get_translation )and self .parent_app :
+ return get_translation (self .parent_app .current_selected_language ,key ,default_text )
+ return default_text
+
+ def _retranslate_ui (self ):
+ self .setWindowTitle (self ._tr ("fav_posts_dialog_title","Favorite Posts"))
+ self .status_label .setText (self ._tr ("fav_posts_loading_status","Loading favorite posts..."))
+ self .search_input .setPlaceholderText (self ._tr ("fav_posts_search_placeholder","Search posts (title, creator name, ID, service)..."))
+ self .select_all_button .setText (self ._tr ("fav_posts_select_all_button","Select All"))
+ self .deselect_all_button .setText (self ._tr ("fav_posts_deselect_all_button","Deselect All"))
+ self .download_button .setText (self ._tr ("fav_posts_download_selected_button","Download Selected"))
+ self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
+
+ def _logger (self ,message ):
+ if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
+ self .parent_app .log_signal .emit (f"[FavPostsDialog] {message }")
+ else :
+ print (f"[FavPostsDialog] {message }")
+
+ def _load_creator_names_from_file (self ):
+ """Loads creator id-name-service mappings from creators.txt."""
+ self ._logger ("Attempting to load creators.json for Favorite Posts Dialog.")
+
+ if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
+ base_path_for_creators =sys ._MEIPASS
+ self ._logger (f" Running bundled. Using _MEIPASS: {base_path_for_creators }")
+ else :
+ base_path_for_creators =self .parent_app .app_base_dir
+ self ._logger (f" Not bundled or _MEIPASS unavailable. Using app_base_dir: {base_path_for_creators }")
+ creators_file_path = os.path.join(base_path_for_creators, "data", "creators.json")
+ self ._logger (f"Full path to creators.json: {creators_file_path }")
+
+ if not os .path .exists (creators_file_path ):
+ self ._logger (f"Warning: 'creators.json' not found at {creators_file_path }. Creator names will not be displayed.")
+ return
+
+ try :
+ with open (creators_file_path ,'r',encoding ='utf-8')as f :
+ loaded_data =json .load (f )
+
+ if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ):
+ creators_list =loaded_data [0 ]
+ elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ):
+ creators_list =loaded_data
+ else :
+ self ._logger (f"Warning: 'creators.json' has an unexpected format. Expected a list of lists or a flat list of creator objects.")
+ return
+
+ for creator_data in creators_list :
+ creator_id =creator_data .get ("id")
+ name =creator_data .get ("name")
+ service =creator_data .get ("service")
+ if creator_id and name and service :
+ self .creator_name_cache [(service .lower (),str (creator_id ))]=name
+ self ._logger (f"Successfully loaded {len (self .creator_name_cache )} creator names from 'creators.json'.")
+ except Exception as e :
+ self ._logger (f"Error loading 'creators.json': {e }")
+
+ def _start_fetching_favorite_posts (self ):
+ self .download_button .setEnabled (False )
+ self .status_label .setText ("Initializing favorite posts fetch...")
+
+ self .fetcher_thread =FavoritePostsFetcherThread (
+ self .cookies_config ,
+ self .parent_app .log_signal .emit ,
+ target_domain_preference =self .target_domain_preference_for_this_fetch
+ )
+ self .fetcher_thread .status_update .connect (self ._update_status_label_from_key )
+ self .fetcher_thread .finished .connect (self ._on_fetch_completed )
+ self .fetcher_thread .progress_bar_update .connect (self ._set_progress_bar_value )
+ self .progress_bar .setVisible (True )
+ self .fetcher_thread .start ()
+
+ def _set_progress_bar_value (self ,value ,maximum ):
+ if maximum ==0 :
+ self .progress_bar .setRange (0 ,0 )
+ self .progress_bar .setValue (0 )
+ else :
+ self .progress_bar .setRange (0 ,maximum )
+ self .progress_bar .setValue (value )
+
+ def _on_fetch_completed (self ,fetched_posts_list ,status_key ):
+ self .progress_bar .setVisible (False )
+
+ proceed_to_display_posts =False
+ show_error_message_box =False
+ message_box_title_key ="fav_posts_fetch_error_title"
+ message_box_text_key ="fav_posts_fetch_error_message"
+ message_box_params ={'domain':self .target_domain_preference_for_this_fetch or "platform",'error_message_part':""}
+ status_label_text_key =None
+
+ if status_key =="KEY_FETCH_SUCCESS":
+ proceed_to_display_posts =True
+ elif status_key and status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_")and fetched_posts_list :
+ displayable_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","").replace ("_"," ")
+ self ._logger (f"Partial success with posts: {status_key } -> {displayable_detail }")
+
+
+ proceed_to_display_posts =True
+ elif status_key :
+ specific_domain_msg_part =f" for {self .target_domain_preference_for_this_fetch }"if self .target_domain_preference_for_this_fetch else ""
+
+ if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC":
+ status_label_text_key ="fav_posts_cookies_required_error"
+ self ._logger (f"Cookie error: {status_key }. Showing help dialog.")
+ cookie_help_dialog =CookieHelpDialog (self )
+ cookie_help_dialog .exec_ ()
+ elif status_key =="KEY_AUTH_FAILED":
+ status_label_text_key ="fav_posts_auth_failed_title"
+ self ._logger (f"Auth error: {status_key }. Showing help dialog.")
+ QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"),
+ self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part ))
+ cookie_help_dialog =CookieHelpDialog (self )
+ cookie_help_dialog .exec_ ()
+ elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
+ status_label_text_key ="fav_posts_no_posts_found_status"
+ self ._logger (status_key )
+ elif status_key .startswith ("KEY_FETCH_CANCELLED"):
+ status_label_text_key ="fav_posts_fetch_cancelled_status"
+ self ._logger (status_key )
+ else :
+ displayable_error_detail =status_key
+ if status_key .startswith ("KEY_FETCH_FAILED_GENERIC_"):
+ displayable_error_detail =status_key .replace ("KEY_FETCH_FAILED_GENERIC_","").replace ("_"," ")
+ elif status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_"):
+ displayable_error_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","Partial success but no posts: ").replace ("_"," ")
+
+ message_box_params ['error_message_part']=f":\n\n{displayable_error_detail }"if displayable_error_detail else ""
+ status_label_text_key ="fav_posts_fetch_error_message"
+ show_error_message_box =True
+ self ._logger (f"Fetch error: {status_key } -> {displayable_error_detail }")
+
+ if status_label_text_key :
+ self .status_label .setText (self ._tr (status_label_text_key ,status_label_text_key ).format (**message_box_params ))
+ if show_error_message_box :
+ QMessageBox .critical (self ,self ._tr (message_box_title_key ),self ._tr (message_box_text_key ).format (**message_box_params ))
+
+ self .download_button .setEnabled (False )
+ return
+
+
+ if not proceed_to_display_posts :
+ if not status_label_text_key :
+ self .status_label .setText (self ._tr ("fav_posts_cookies_required_error","Error: Cookies are required for favorite posts but could not be loaded."))
+ self .download_button .setEnabled (False )
+ return
+
+ if not self .creator_name_cache :
+ self ._logger ("Warning: Creator name cache is empty. Names will not be resolved from creators.json. Displaying IDs instead.")
+ else :
+ self ._logger (f"Creator name cache has {len (self .creator_name_cache )} entries. Attempting to resolve names...")
+ sample_keys =list (self .creator_name_cache .keys ())[:3 ]
+ if sample_keys :
+ self ._logger (f"Sample keys from creator_name_cache: {sample_keys }")
+
+
+ processed_one_missing_log =False
+ for post_entry in fetched_posts_list :
+ service_from_post =post_entry .get ('service','')
+ creator_id_from_post =post_entry .get ('creator_id','')
+
+ lookup_key_service =service_from_post .lower ()
+ lookup_key_id =str (creator_id_from_post )
+ lookup_key_tuple =(lookup_key_service ,lookup_key_id )
+
+ resolved_name =self .creator_name_cache .get (lookup_key_tuple )
+
+ if resolved_name :
+ post_entry ['creator_name_resolved']=resolved_name
+ else :
+ post_entry ['creator_name_resolved']=str (creator_id_from_post )
+ if not processed_one_missing_log and self .creator_name_cache :
+ self ._logger (f"Debug: Name not found for key {lookup_key_tuple }. Using ID '{creator_id_from_post }'.")
+ processed_one_missing_log =True
+
+ self .all_fetched_posts =fetched_posts_list
+
+ if not self .all_fetched_posts :
+ self .status_label .setText (self ._tr ("fav_posts_no_posts_found_status","No favorite posts found."))
+ self .download_button .setEnabled (False )
+ return
+
+ try :
+ self ._populate_post_list_widget ()
+ self .status_label .setText (self ._tr ("fav_posts_found_status","{count} favorite post(s) found.").format (count =len (self .all_fetched_posts )))
+ self .download_button .setEnabled (True )
+ except Exception as e :
+ self .status_label .setText (self ._tr ("fav_posts_display_error_status","Error displaying posts: {error}").format (error =str (e )))
+ self ._logger (f"Error during _populate_post_list_widget: {e }\n{traceback .format_exc (limit =3 )}")
+ QMessageBox .critical (self ,self ._tr ("fav_posts_ui_error_title","UI Error"),self ._tr ("fav_posts_ui_error_message","Could not display favorite posts: {error}").format (error =str (e )))
+ self .download_button .setEnabled (False )
+
+
+ def _find_best_known_name_match_in_title (self ,title_raw ):
+ if not title_raw or not self .known_names_list_ref :
+ return None
+
+ title_lower =title_raw .lower ()
+ best_match_known_name_primary =None
+ longest_match_len =0
+
+ for known_entry in self .known_names_list_ref :
+ aliases_to_check =set ()
+ for alias_val in known_entry .get ("aliases",[]):
+ aliases_to_check .add (alias_val )
+ if not known_entry .get ("is_group",False ):
+ aliases_to_check .add (known_entry ["name"])
+ sorted_aliases_for_entry =sorted (list (aliases_to_check ),key =len ,reverse =True )
+
+ for alias in sorted_aliases_for_entry :
+ alias_lower =alias .lower ()
+ if not alias_lower :
+ continue
+ if re .search (r'\b'+re .escape (alias_lower )+r'\b',title_lower ):
+ if len (alias_lower )>longest_match_len :
+ longest_match_len =len (alias_lower )
+ best_match_known_name_primary =known_entry ["name"]
+ break
+ return best_match_known_name_primary
+
+ def _populate_post_list_widget (self ,posts_to_display =None ):
+ self .post_list_widget .clear ()
+
+ source_list_for_grouping =posts_to_display if posts_to_display is not None else self .all_fetched_posts
+ grouped_posts ={}
+ for post in source_list_for_grouping :
+ service =post .get ('service','unknown_service')
+ creator_id =post .get ('creator_id','unknown_id')
+ group_key =(service ,creator_id )
+ if group_key not in grouped_posts :
+ grouped_posts [group_key ]=[]
+ grouped_posts [group_key ].append (post )
+
+ sorted_group_keys =sorted (grouped_posts .keys (),key =lambda x :(x [0 ].lower (),x [1 ].lower ()))
+
+ self .displayable_grouped_posts ={
+ key :sorted (grouped_posts [key ],key =lambda p :(p .get ('added_date')or ''),reverse =True )
+ for key in sorted_group_keys
+ }
+ for service ,creator_id_val in sorted_group_keys :
+ creator_name_display =self .creator_name_cache .get (
+ (service .lower (),str (creator_id_val )),
+ str (creator_id_val )
+ )
+ artist_header_display_text =f"{creator_name_display } ({service .capitalize ()} / {creator_id_val })"
+ artist_header_item =QListWidgetItem (f"🎨 {artist_header_display_text }")
+ artist_header_item .setFlags (Qt .NoItemFlags )
+ font =artist_header_item .font ()
+ font .setBold (True )
+ font .setPointSize (font .pointSize ()+1 )
+ artist_header_item .setFont (font )
+ artist_header_item .setForeground (Qt .cyan )
+ self .post_list_widget .addItem (artist_header_item )
+ for post_data in self .displayable_grouped_posts [(service ,creator_id_val )]:
+ post_title_raw =post_data .get ('title','Untitled Post')
+ found_known_name_primary =self ._find_best_known_name_match_in_title (post_title_raw )
+
+ plain_text_title_for_list_item =post_title_raw
+ if found_known_name_primary :
+ suffix_text =f" [Known - {found_known_name_primary }]"
+ post_data ['suffix_for_display']=suffix_text
+ plain_text_title_for_list_item =post_title_raw +suffix_text
+ else :
+ post_data .pop ('suffix_for_display',None )
+
+ list_item =QListWidgetItem (self .post_list_widget )
+ list_item .setText (plain_text_title_for_list_item )
+ list_item .setFlags (list_item .flags ()|Qt .ItemIsUserCheckable )
+ list_item .setCheckState (Qt .Unchecked )
+ list_item .setData (Qt .UserRole ,post_data )
+ self .post_list_widget .addItem (list_item )
+
+ def _filter_post_list_display (self ):
+ search_text =self .search_input .text ().lower ().strip ()
+ if not search_text :
+ self ._populate_post_list_widget (self .all_fetched_posts )
+ return
+
+ filtered_posts_to_group =[]
+ for post in self .all_fetched_posts :
+ matches_post_title =search_text in post .get ('title','').lower ()
+ matches_creator_name =search_text in post .get ('creator_name_resolved','').lower ()
+ matches_creator_id =search_text in post .get ('creator_id','').lower ()
+ matches_service =search_text in post ['service'].lower ()
+
+ if matches_post_title or matches_creator_name or matches_creator_id or matches_service :
+ filtered_posts_to_group .append (post )
+
+ self ._populate_post_list_widget (filtered_posts_to_group )
+
+ def _select_all_items (self ):
+ for i in range (self .post_list_widget .count ()):
+ item =self .post_list_widget .item (i )
+ if item and item .flags ()&Qt .ItemIsUserCheckable :
+ item .setCheckState (Qt .Checked )
+
+ def _deselect_all_items (self ):
+ for i in range (self .post_list_widget .count ()):
+ item =self .post_list_widget .item (i )
+ if item and item .flags ()&Qt .ItemIsUserCheckable :
+ item .setCheckState (Qt .Unchecked )
+
+ def _accept_selection_action (self ):
+ self .selected_posts_data =[]
+ for i in range (self .post_list_widget .count ()):
+ item =self .post_list_widget .item (i )
+ if item and item .checkState ()==Qt .Checked :
+ post_data_for_download =item .data (Qt .UserRole )
+ self .selected_posts_data .append (post_data_for_download )
+
+ if not self .selected_posts_data :
+ QMessageBox .information (self ,self ._tr ("fav_posts_no_selection_title","No Selection"),self ._tr ("fav_posts_no_selection_message","Please select at least one post to download."))
+ return
+ self .accept ()
+
+ def get_selected_posts (self ):
+ return self .selected_posts_data
diff --git a/src/ui/dialogs/FutureSettingsDialog.py b/src/ui/dialogs/FutureSettingsDialog.py
new file mode 100644
index 0000000..ee0261b
--- /dev/null
+++ b/src/ui/dialogs/FutureSettingsDialog.py
@@ -0,0 +1,202 @@
+# --- Standard Library Imports ---
+import os
+
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import Qt, QStandardPaths
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
+ QGroupBox, QComboBox, QMessageBox
+)
+
+# --- Local Application Imports ---
+# This assumes the new project structure is in place.
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+from ...config.constants import (
+ THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY
+)
+
+
+class FutureSettingsDialog(QDialog):
+ """
+ A dialog for managing application-wide settings like theme, language,
+ and saving the default download path.
+ """
+ def __init__(self, parent_app_ref, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ parent_app_ref (DownloaderApp): A reference to the main application window.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app_ref
+ self.setModal(True)
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically
+ screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
+ scale_factor = screen_height / 768.0
+ base_min_w, base_min_h = 380, 250
+ 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)
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ layout = QVBoxLayout(self)
+
+ # --- Appearance Settings ---
+ self.appearance_group_box = QGroupBox()
+ appearance_layout = QVBoxLayout(self.appearance_group_box)
+ self.theme_toggle_button = QPushButton()
+ self.theme_toggle_button.clicked.connect(self._toggle_theme)
+ appearance_layout.addWidget(self.theme_toggle_button)
+ layout.addWidget(self.appearance_group_box)
+
+ # --- Language Settings ---
+ self.language_group_box = QGroupBox()
+ language_group_layout = QVBoxLayout(self.language_group_box)
+ self.language_selection_layout = QHBoxLayout()
+ self.language_label = QLabel()
+ self.language_selection_layout.addWidget(self.language_label)
+ self.language_combo_box = QComboBox()
+ self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
+ self.language_selection_layout.addWidget(self.language_combo_box, 1)
+ language_group_layout.addLayout(self.language_selection_layout)
+ layout.addWidget(self.language_group_box)
+
+ # --- Download Settings ---
+ self.download_settings_group_box = QGroupBox()
+ download_settings_layout = QVBoxLayout(self.download_settings_group_box)
+ self.save_path_button = QPushButton()
+ self.save_path_button.clicked.connect(self._save_download_path)
+ download_settings_layout.addWidget(self.save_path_button)
+ layout.addWidget(self.download_settings_group_box)
+
+ layout.addStretch(1)
+
+ # --- OK Button ---
+ self.ok_button = QPushButton()
+ self.ok_button.clicked.connect(self.accept)
+ layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
+ self.appearance_group_box.setTitle(self._tr("appearance_group_title", "Appearance"))
+ self.language_group_box.setTitle(self._tr("language_group_title", "Language Settings"))
+ self.download_settings_group_box.setTitle(self._tr("settings_download_group_title", "Download Settings"))
+ self.language_label.setText(self._tr("language_label", "Language:"))
+ self._update_theme_toggle_button_text()
+ self._populate_language_combo_box()
+
+ self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path"))
+ self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
+ self.ok_button.setText(self._tr("ok_button", "OK"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app.current_theme == "dark":
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+ else:
+ self.setStyleSheet("")
+
+ def _update_theme_toggle_button_text(self):
+ """Updates the theme button text and tooltip based on the current theme."""
+ if self.parent_app.current_theme == "dark":
+ self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode"))
+ self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_light", "Change the application appearance to light."))
+ else:
+ self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode"))
+ self.theme_toggle_button.setToolTip(self._tr("theme_tooltip_dark", "Change the application appearance to dark."))
+
+ def _toggle_theme(self):
+ """Toggles the application theme and updates the UI."""
+ new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
+ self.parent_app.apply_theme(new_theme)
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _populate_language_combo_box(self):
+ """Populates the language dropdown with available languages."""
+ self.language_combo_box.blockSignals(True)
+ self.language_combo_box.clear()
+ languages = [
+ ("en","English"),
+ ("ja","日本語 (Japanese)"),
+ ("fr","Français (French)"),
+ ("de","Deutsch (German)"),
+ ("es","Español (Spanish)"),
+ ("pt","Português (Portuguese)"),
+ ("ru","Русский (Russian)"),
+ ("zh_CN","简体中文 (Simplified Chinese)"),
+ ("zh_TW","繁體中文 (Traditional Chinese)"),
+ ("ko","한국어 (Korean)")
+ ]
+ for lang_code, lang_name in languages:
+ self.language_combo_box.addItem(lang_name, lang_code)
+ if self.parent_app.current_selected_language == lang_code:
+ self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1)
+ self.language_combo_box.blockSignals(False)
+
+ def _language_selection_changed(self, index):
+ """Handles the user selecting a new language."""
+ selected_lang_code = self.language_combo_box.itemData(index)
+ if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language:
+ self.parent_app.current_selected_language = selected_lang_code
+ self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code)
+ self.parent_app.settings.sync()
+
+ self._retranslate_ui()
+
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Information)
+ msg_box.setWindowTitle(self._tr("language_change_title", "Language Changed"))
+ msg_box.setText(self._tr("language_change_message", "A restart is required..."))
+ msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
+ restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
+ ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
+ msg_box.setDefaultButton(ok_button)
+ msg_box.exec_()
+
+ if msg_box.clickedButton() == restart_button:
+ self.parent_app._request_restart_application()
+
+ def _save_download_path(self):
+ """Saves the current download path from the main window to settings."""
+ if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
+ current_path = self.parent_app.dir_input.text().strip()
+ if current_path:
+ if os.path.isdir(current_path):
+ self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
+ self.parent_app.settings.sync()
+ QMessageBox.information(self,
+ self._tr("settings_save_path_success_title", "Path Saved"),
+ self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
+ else:
+ QMessageBox.warning(self,
+ self._tr("settings_save_path_invalid_title", "Invalid Path"),
+ self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
+ else:
+ QMessageBox.warning(self,
+ self._tr("settings_save_path_empty_title", "Empty Path"),
+ self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
+ else:
+ QMessageBox.critical(self, "Error", "Could not access download path input from main application.")
diff --git a/src/ui/dialogs/HelpGuideDialog.py b/src/ui/dialogs/HelpGuideDialog.py
new file mode 100644
index 0000000..9b721cd
--- /dev/null
+++ b/src/ui/dialogs/HelpGuideDialog.py
@@ -0,0 +1,192 @@
+# --- Standard Library Imports ---
+import os
+import sys
+
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import QUrl, QSize, Qt
+from PyQt5.QtGui import QIcon
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
+ QStackedWidget, QScrollArea, QFrame, QWidget
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+
+
+class TourStepWidget(QWidget):
+ """
+ A custom widget representing a single step or page in the feature guide.
+ It neatly formats a title and its corresponding content.
+ """
+ def __init__(self, title_text, content_text, parent=None):
+ super().__init__(parent)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(20, 20, 20, 20)
+ layout.setSpacing(10)
+
+ title_label = QLabel(title_text)
+ title_label.setAlignment(Qt.AlignCenter)
+ title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
+ layout.addWidget(title_label)
+
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setFrameShape(QFrame.NoFrame)
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ scroll_area.setStyleSheet("background-color: transparent;")
+
+ content_label = QLabel(content_text)
+ content_label.setWordWrap(True)
+ content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+ content_label.setTextFormat(Qt.RichText)
+ content_label.setOpenExternalLinks(True) # Allow opening links in the content
+ content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
+ scroll_area.setWidget(content_label)
+ layout.addWidget(scroll_area, 1)
+
+
+class HelpGuideDialog (QDialog ):
+ """A multi-page dialog for displaying the feature guide."""
+ def __init__ (self ,steps_data ,parent_app ,parent =None ):
+ super ().__init__ (parent )
+ self .current_step =0
+ self .steps_data =steps_data
+ self .parent_app =parent_app
+
+ app_icon =get_app_icon_object ()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ self .setModal (True )
+ self .setFixedSize (650 ,600 )
+
+
+ current_theme_style =""
+ if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
+ if hasattr (self .parent_app ,'get_dark_theme'):
+ current_theme_style =self .parent_app .get_dark_theme ()
+
+
+ self .setStyleSheet (current_theme_style if current_theme_style else """
+ QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
+ QLabel { color: #E0E0E0; }
+ QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
+ QPushButton:hover { background-color: #656565; }
+ QPushButton:pressed { background-color: #4A4A4A; }
+ """)
+ self ._init_ui ()
+ if self .parent_app :
+ self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
+
+ def _tr (self ,key ,default_text =""):
+ """Helper to get translation based on current app language."""
+ if callable (get_translation )and self .parent_app :
+ return get_translation (self .parent_app .current_selected_language ,key ,default_text )
+ return default_text
+
+
+ def _init_ui (self ):
+ main_layout =QVBoxLayout (self )
+ main_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ main_layout .setSpacing (0 )
+
+ self .stacked_widget =QStackedWidget ()
+ main_layout .addWidget (self .stacked_widget ,1 )
+
+ self .tour_steps_widgets =[]
+ for title ,content in self .steps_data :
+ step_widget =TourStepWidget (title ,content )
+ self .tour_steps_widgets .append (step_widget )
+ self .stacked_widget .addWidget (step_widget )
+
+ self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
+
+ buttons_layout =QHBoxLayout ()
+ buttons_layout .setContentsMargins (15 ,10 ,15 ,15 )
+ buttons_layout .setSpacing (10 )
+
+ self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
+ self .back_button .clicked .connect (self ._previous_step )
+ self .back_button .setEnabled (False )
+
+ if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
+ assets_base_dir =sys ._MEIPASS
+ else :
+ # Go up three levels from this file's directory (src/ui/dialogs) to the project root
+ assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
+
+ github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
+ instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png")
+ discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png")
+
+ self .github_button =QPushButton (QIcon (github_icon_path ),"")
+ self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
+ self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
+
+ icon_size =QSize (24 ,24 )
+ self .github_button .setIconSize (icon_size )
+ self .instagram_button .setIconSize (icon_size )
+ self .Discord_button .setIconSize (icon_size )
+
+ self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
+ self .next_button .clicked .connect (self ._next_step_action )
+ self .next_button .setDefault (True )
+ self .github_button .clicked .connect (self ._open_github_link )
+ self .instagram_button .clicked .connect (self ._open_instagram_link )
+ self .Discord_button .clicked .connect (self ._open_Discord_link )
+ self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)"))
+ self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)"))
+ self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)"))
+
+
+ social_layout =QHBoxLayout ()
+ social_layout .setSpacing (10 )
+ social_layout .addWidget (self .github_button )
+ social_layout .addWidget (self .instagram_button )
+ social_layout .addWidget (self .Discord_button )
+
+ while buttons_layout .count ():
+ item =buttons_layout .takeAt (0 )
+ if item .widget ():
+ item .widget ().setParent (None )
+ elif item .layout ():
+ pass
+ buttons_layout .addLayout (social_layout )
+ buttons_layout .addStretch (1 )
+ buttons_layout .addWidget (self .back_button )
+ buttons_layout .addWidget (self .next_button )
+ main_layout .addLayout (buttons_layout )
+ self ._update_button_states ()
+
+ def _next_step_action (self ):
+ if self .current_step 0 :
+ self .current_step -=1
+ self .stacked_widget .setCurrentIndex (self .current_step )
+ self ._update_button_states ()
+
+ def _update_button_states (self ):
+ if self .current_step ==len (self .tour_steps_widgets )-1 :
+ self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
+ else :
+ self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
+ self .back_button .setEnabled (self .current_step >0 )
+
+ def _open_github_link (self ):
+ QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587"))
+
+ def _open_instagram_link (self ):
+ QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/"))
+
+ def _open_Discord_link (self ):
+ QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN"))
\ No newline at end of file
diff --git a/src/ui/dialogs/KnownNamesFilterDialog.py b/src/ui/dialogs/KnownNamesFilterDialog.py
new file mode 100644
index 0000000..c94af6d
--- /dev/null
+++ b/src/ui/dialogs/KnownNamesFilterDialog.py
@@ -0,0 +1,150 @@
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
+ QListWidgetItem, QPushButton, QVBoxLayout
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+
+
+class KnownNamesFilterDialog(QDialog):
+ """
+ A dialog to select names from the Known.txt list to add to the main
+ character filter input field. This provides a convenient way for users
+
+ to reuse their saved names and groups for filtering downloads.
+ """
+
+ def __init__(self, known_names_list, parent_app_ref, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ known_names_list (list): A list of known name objects (dicts) from Known.txt.
+ parent_app_ref (DownloaderApp): A reference to the main application window.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.parent_app = parent_app_ref
+ self.setModal(True)
+ self.all_known_name_entries = sorted(known_names_list, key=lambda x: x['name'].lower())
+ self.selected_entries_to_return = []
+
+ # --- Basic Window Setup ---
+ app_icon = get_app_icon_object()
+ if app_icon and not app_icon.isNull():
+ self.setWindowIcon(app_icon)
+
+ # Set window size dynamically
+ screen_geometry = QApplication.primaryScreen().availableGeometry()
+ base_width, base_height = 460, 450
+ scale_factor_h = screen_geometry.height() / 1080.0
+ effective_scale_factor = max(0.75, min(scale_factor_h, 1.5))
+ self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor))
+ self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
+
+ # --- Initialize UI and Apply Theming ---
+ self._init_ui()
+ self._retranslate_ui()
+ self._apply_theme()
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts for the dialog."""
+ main_layout = QVBoxLayout(self)
+
+ self.search_input = QLineEdit()
+ self.search_input.textChanged.connect(self._filter_list_display)
+ main_layout.addWidget(self.search_input)
+
+ self.names_list_widget = QListWidget()
+ self._populate_list_widget()
+ main_layout.addWidget(self.names_list_widget)
+
+ # --- Control Buttons ---
+ buttons_layout = QHBoxLayout()
+
+ self.select_all_button = QPushButton()
+ self.select_all_button.clicked.connect(self._select_all_items)
+ buttons_layout.addWidget(self.select_all_button)
+
+ self.deselect_all_button = QPushButton()
+ self.deselect_all_button.clicked.connect(self._deselect_all_items)
+ buttons_layout.addWidget(self.deselect_all_button)
+ buttons_layout.addStretch(1)
+
+ self.add_button = QPushButton()
+ self.add_button.clicked.connect(self._accept_selection_action)
+ self.add_button.setDefault(True)
+ buttons_layout.addWidget(self.add_button)
+
+ self.cancel_button = QPushButton()
+ self.cancel_button.clicked.connect(self.reject)
+ buttons_layout.addWidget(self.cancel_button)
+ main_layout.addLayout(buttons_layout)
+
+ def _tr(self, key, default_text=""):
+ """Helper to get translation based on the main application's current language."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _retranslate_ui(self):
+ """Sets the text for all translatable UI elements."""
+ self.setWindowTitle(self._tr("known_names_filter_dialog_title", "Add Known Names to Filter"))
+ self.search_input.setPlaceholderText(self._tr("known_names_filter_search_placeholder", "Search names..."))
+ self.select_all_button.setText(self._tr("known_names_filter_select_all_button", "Select All"))
+ self.deselect_all_button.setText(self._tr("known_names_filter_deselect_all_button", "Deselect All"))
+ self.add_button.setText(self._tr("known_names_filter_add_selected_button", "Add Selected"))
+ self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+
+ def _populate_list_widget(self):
+ """Populates the list widget with the known names."""
+ self.names_list_widget.clear()
+ for entry_obj in self.all_known_name_entries:
+ item = QListWidgetItem(entry_obj['name'])
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
+ item.setCheckState(Qt.Unchecked)
+ item.setData(Qt.UserRole, entry_obj)
+ self.names_list_widget.addItem(item)
+
+ def _filter_list_display(self):
+ """Filters the displayed list based on the search input text."""
+ search_text_lower = self.search_input.text().lower()
+ for i in range(self.names_list_widget.count()):
+ item = self.names_list_widget.item(i)
+ entry_obj = item.data(Qt.UserRole)
+ matches_search = not search_text_lower or search_text_lower in entry_obj['name'].lower()
+ item.setHidden(not matches_search)
+
+ def _select_all_items(self):
+ """Checks all visible items in the list widget."""
+ for i in range(self.names_list_widget.count()):
+ item = self.names_list_widget.item(i)
+ if not item.isHidden():
+ item.setCheckState(Qt.Checked)
+
+ def _deselect_all_items(self):
+ """Unchecks all items in the list widget."""
+ for i in range(self.names_list_widget.count()):
+ self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
+
+ def _accept_selection_action(self):
+ """Gathers the selected entries and accepts the dialog."""
+ self.selected_entries_to_return = []
+ for i in range(self.names_list_widget.count()):
+ item = self.names_list_widget.item(i)
+ if item.checkState() == Qt.Checked:
+ self.selected_entries_to_return.append(item.data(Qt.UserRole))
+ self.accept()
+
+ def get_selected_entries(self):
+ """Returns the list of known name entries selected by the user."""
+ return self.selected_entries_to_return
diff --git a/src/ui/dialogs/TourDialog.py b/src/ui/dialogs/TourDialog.py
new file mode 100644
index 0000000..094fa69
--- /dev/null
+++ b/src/ui/dialogs/TourDialog.py
@@ -0,0 +1,217 @@
+# --- Standard Library Imports ---
+import os
+import sys
+
+# --- PyQt5 Imports ---
+from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
+ QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
+)
+
+# --- Local Application Imports ---
+from ...i18n.translator import get_translation
+from ..main_window import get_app_icon_object
+from ...config.constants import (
+ CONFIG_ORGANIZATION_NAME
+)
+
+
+class TourStepWidget(QWidget):
+ """
+ A custom widget representing a single step or page in the feature tour.
+ It neatly formats a title and its corresponding content.
+ """
+ def __init__(self, title_text, content_text, parent=None):
+ super().__init__(parent)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(20, 20, 20, 20)
+ layout.setSpacing(10)
+
+ title_label = QLabel(title_text)
+ title_label.setAlignment(Qt.AlignCenter)
+ title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
+ layout.addWidget(title_label)
+
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setFrameShape(QFrame.NoFrame)
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ scroll_area.setStyleSheet("background-color: transparent;")
+
+ content_label = QLabel(content_text)
+ content_label.setWordWrap(True)
+ content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+ content_label.setTextFormat(Qt.RichText)
+ content_label.setOpenExternalLinks(True)
+ content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
+ scroll_area.setWidget(content_label)
+ layout.addWidget(scroll_area, 1)
+
+
+class TourDialog(QDialog):
+ """
+ A dialog that shows a multi-page tour to the user on first launch.
+ Includes a "Never show again" checkbox and uses QSettings to remember this preference.
+ """
+ tour_finished_normally = pyqtSignal()
+ tour_skipped = pyqtSignal()
+
+ # Constants for QSettings
+ CONFIG_APP_NAME_TOUR = "ApplicationTour"
+ TOUR_SHOWN_KEY = "neverShowTourAgainV19"
+
+ def __init__(self, parent_app, parent=None):
+ """
+ Initializes the dialog.
+
+ Args:
+ parent_app (DownloaderApp): A reference to the main application window.
+ parent (QWidget, optional): The parent widget. Defaults to None.
+ """
+ super().__init__(parent)
+ self.settings = QSettings(CONFIG_ORGANIZATION_NAME, self.CONFIG_APP_NAME_TOUR)
+ self.current_step = 0
+ self.parent_app = parent_app
+
+ self.setWindowIcon(get_app_icon_object())
+ self.setModal(True)
+ self.setFixedSize(600, 620)
+
+ self._init_ui()
+ self._apply_theme()
+ self._center_on_screen()
+
+ def _tr(self, key, default_text=""):
+ """Helper for translation."""
+ if callable(get_translation) and self.parent_app:
+ return get_translation(self.parent_app.current_selected_language, key, default_text)
+ return default_text
+
+ def _init_ui(self):
+ """Initializes all UI components and layouts."""
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ self.stacked_widget = QStackedWidget()
+ main_layout.addWidget(self.stacked_widget, 1)
+
+ # Load content for each step
+ steps_content = [
+ ("tour_dialog_step1_title", "tour_dialog_step1_content"),
+ ("tour_dialog_step2_title", "tour_dialog_step2_content"),
+ ("tour_dialog_step3_title", "tour_dialog_step3_content"),
+ ("tour_dialog_step4_title", "tour_dialog_step4_content"),
+ ("tour_dialog_step5_title", "tour_dialog_step5_content"),
+ ("tour_dialog_step6_title", "tour_dialog_step6_content"),
+ ("tour_dialog_step7_title", "tour_dialog_step7_content"),
+ ("tour_dialog_step8_title", "tour_dialog_step8_content"),
+ ]
+
+ self.tour_steps_widgets = []
+ for title_key, content_key in steps_content:
+ title = self._tr(title_key, title_key)
+ content = self._tr(content_key, "Content not found.")
+ step_widget = TourStepWidget(title, content)
+ self.tour_steps_widgets.append(step_widget)
+ self.stacked_widget.addWidget(step_widget)
+
+ self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
+
+ # --- Bottom Controls ---
+ bottom_controls_layout = QVBoxLayout()
+ bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
+ bottom_controls_layout.setSpacing(12)
+
+ self.never_show_again_checkbox = QCheckBox(self._tr("tour_dialog_never_show_checkbox", "Never show this tour again"))
+ bottom_controls_layout.addWidget(self.never_show_again_checkbox, 0, Qt.AlignLeft)
+
+ buttons_layout = QHBoxLayout()
+ buttons_layout.setSpacing(10)
+ self.skip_button = QPushButton(self._tr("tour_dialog_skip_button", "Skip Tour"))
+ self.skip_button.clicked.connect(self._skip_tour_action)
+ self.back_button = QPushButton(self._tr("tour_dialog_back_button", "Back"))
+ self.back_button.clicked.connect(self._previous_step)
+ self.next_button = QPushButton(self._tr("tour_dialog_next_button", "Next"))
+ self.next_button.clicked.connect(self._next_step_action)
+ self.next_button.setDefault(True)
+
+ buttons_layout.addWidget(self.skip_button)
+ buttons_layout.addStretch(1)
+ buttons_layout.addWidget(self.back_button)
+ buttons_layout.addWidget(self.next_button)
+
+ bottom_controls_layout.addLayout(buttons_layout)
+ main_layout.addLayout(bottom_controls_layout)
+
+ self._update_button_states()
+
+ def _apply_theme(self):
+ """Applies the current theme from the parent application."""
+ if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
+ self.setStyleSheet(self.parent_app.get_dark_theme())
+ else:
+ self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
+
+ def _center_on_screen(self):
+ """Centers the dialog on the screen."""
+ try:
+ screen_geo = QApplication.primaryScreen().availableGeometry()
+ self.move(screen_geo.center() - self.rect().center())
+ except Exception as e:
+ print(f"[TourDialog] Error centering dialog: {e}")
+
+ def _next_step_action(self):
+ """Moves to the next step or finishes the tour."""
+ if self.current_step < len(self.tour_steps_widgets) - 1:
+ self.current_step += 1
+ self.stacked_widget.setCurrentIndex(self.current_step)
+ else:
+ self._finish_tour_action()
+ self._update_button_states()
+
+ def _previous_step(self):
+ """Moves to the previous step."""
+ if self.current_step > 0:
+ self.current_step -= 1
+ self.stacked_widget.setCurrentIndex(self.current_step)
+ self._update_button_states()
+
+ def _update_button_states(self):
+ """Updates the state and text of navigation buttons."""
+ is_last_step = self.current_step == len(self.tour_steps_widgets) - 1
+ self.next_button.setText(self._tr("tour_dialog_finish_button", "Finish") if is_last_step else self._tr("tour_dialog_next_button", "Next"))
+ self.back_button.setEnabled(self.current_step > 0)
+
+ def _skip_tour_action(self):
+ """Handles the action when the tour is skipped."""
+ self._save_settings_if_checked()
+ self.tour_skipped.emit()
+ self.reject()
+
+ def _finish_tour_action(self):
+ """Handles the action when the tour is finished normally."""
+ self._save_settings_if_checked()
+ self.tour_finished_normally.emit()
+ self.accept()
+
+ def _save_settings_if_checked(self):
+ """Saves the 'never show again' preference to QSettings."""
+ self.settings.setValue(self.TOUR_SHOWN_KEY, self.never_show_again_checkbox.isChecked())
+ self.settings.sync()
+
+ @staticmethod
+ def should_show_tour():
+ """Checks QSettings to see if the tour should be shown on startup."""
+ settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
+ never_show = settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool)
+ return not never_show
+
+ CONFIG_ORGANIZATION_NAME = CONFIG_ORGANIZATION_NAME
+
+ def closeEvent(self, event):
+ """Ensures settings are saved if the dialog is closed via the 'X' button."""
+ self._skip_tour_action()
+ super().closeEvent(event)
diff --git a/src/ui/dialogs/__init__.py b/src/ui/dialogs/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/ui/dialogs/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/ui/main_window.py b/src/ui/main_window.py
new file mode 100644
index 0000000..59badf1
--- /dev/null
+++ b/src/ui/main_window.py
@@ -0,0 +1,5372 @@
+# --- Standard Library Imports ---
+import sys
+import os
+import time
+import queue
+import traceback
+import html
+import http
+import json
+import re
+import subprocess
+import datetime
+import requests
+import unicodedata
+from collections import deque
+import threading
+from concurrent.futures import Future, ThreadPoolExecutor ,CancelledError
+from urllib .parse import urlparse
+
+# --- PyQt5 Imports ---
+from PyQt5.QtGui import QIcon, QIntValidator, QDesktopServices
+from PyQt5.QtWidgets import (
+ QApplication, QWidget, QLabel, QLineEdit, QTextEdit, QPushButton,
+ QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QListWidget, QRadioButton,
+ QButtonGroup, QCheckBox, QSplitter, QGroupBox, QDialog, QStackedWidget,
+ QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame,
+ QMainWindow, QAction
+)
+from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker
+
+# --- Local Application Imports ---
+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
+from ..core.workers import PostProcessorWorker
+from ..core.workers import PostProcessorSignals
+from ..core.api_client import download_from_api
+from ..core.manager import DownloadManager
+from .assets import get_app_icon_object
+from ..config.constants import *
+from ..utils.file_utils import KNOWN_NAMES, clean_folder_name
+from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
+from ..i18n.translator import get_translation
+from .dialogs.EmptyPopupDialog import EmptyPopupDialog
+from .dialogs.CookieHelpDialog import CookieHelpDialog
+from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog
+from .dialogs.KnownNamesFilterDialog import KnownNamesFilterDialog
+from .dialogs.HelpGuideDialog import HelpGuideDialog
+from .dialogs.FutureSettingsDialog import FutureSettingsDialog
+from .dialogs.ErrorFilesDialog import ErrorFilesDialog
+from .dialogs.DownloadHistoryDialog import DownloadHistoryDialog
+from .dialogs.DownloadExtractedLinksDialog import DownloadExtractedLinksDialog
+from .dialogs.FavoritePostsDialog import FavoritePostsDialog
+from .dialogs.FavoriteArtistsDialog import FavoriteArtistsDialog
+from .dialogs.ConfirmAddAllDialog import ConfirmAddAllDialog
+
+class DynamicFilterHolder:
+ """A thread-safe class to hold and update character filters during a download."""
+ def __init__(self, initial_filters=None):
+ self.lock = threading.Lock()
+ self._filters = initial_filters if initial_filters is not None else []
+
+ def get_filters(self):
+ with self.lock:
+ return [dict(f) for f in self._filters]
+
+ def set_filters(self, new_filters):
+ with self.lock:
+ self._filters = [dict(f) for f in (new_filters if new_filters else [])]
+
+
+class PostProcessorSignals(QObject):
+ """A collection of signals for the DownloaderApp to communicate with itself across threads."""
+ progress_signal = pyqtSignal(str)
+ file_download_status_signal = pyqtSignal(bool)
+ external_link_signal = pyqtSignal(str, str, str, str, str)
+ file_progress_signal = pyqtSignal(str, object)
+ file_successfully_downloaded_signal = pyqtSignal(dict)
+ missed_character_post_signal = pyqtSignal(str, str)
+ finished_signal = pyqtSignal(int, int, bool, list)
+ retryable_file_failed_signal = pyqtSignal(list)
+ permanent_file_failed_signal = pyqtSignal(list)
+
+class DownloaderApp (QWidget ):
+ character_prompt_response_signal =pyqtSignal (bool )
+ log_signal =pyqtSignal (str )
+ add_character_prompt_signal =pyqtSignal (str )
+ overall_progress_signal =pyqtSignal (int ,int )
+ file_successfully_downloaded_signal =pyqtSignal (dict )
+ post_processed_for_history_signal =pyqtSignal (dict )
+ finished_signal =pyqtSignal (int ,int ,bool ,list )
+ external_link_signal =pyqtSignal (str ,str ,str ,str ,str )
+ file_progress_signal =pyqtSignal (str ,object )
+
+
+ def __init__ (self ):
+ super ().__init__ ()
+ self .settings =QSettings (CONFIG_ORGANIZATION_NAME ,CONFIG_APP_NAME_MAIN )
+ if getattr (sys ,'frozen',False ):
+ self .app_base_dir =os .path .dirname (sys .executable )
+ 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 .download_thread =None
+ self .thread_pool =None
+ self .cancellation_event =threading .Event ()
+ self.session_file_path = os.path.join(self.app_base_dir, "appdata","session.json")
+ self.session_lock = threading.Lock()
+ self.interrupted_session_data = None
+ self.is_restore_pending = False
+ self .external_link_download_thread =None
+ self .pause_event =threading .Event ()
+ self .active_futures =[]
+ self .total_posts_to_process =0
+ self .dynamic_character_filter_holder =DynamicFilterHolder ()
+ self .processed_posts_count =0
+ self .creator_name_cache ={}
+ self .log_signal .emit (f"ℹ️ App base directory: {self .app_base_dir }")
+
+ self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+ self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json")
+ self .last_downloaded_files_details =deque (maxlen =3 )
+ self .download_history_candidates =deque (maxlen =8 )
+ self .log_signal .emit (f"ℹ️ Persistent history file path set to: {self .persistent_history_file }")
+ self .final_download_history_entries =[]
+ self .favorite_download_queue =deque ()
+ self .is_processing_favorites_queue =False
+ self .download_counter =0
+ self .favorite_download_queue =deque ()
+ self .permanently_failed_files_for_dialog =[]
+ self .last_link_input_text_for_queue_sync =""
+ self .is_fetcher_thread_running =False
+ self ._restart_pending =False
+ self .is_processing_favorites_queue =False
+ self .download_history_log =deque (maxlen =50 )
+ self .skip_counter =0
+ self .all_kept_original_filenames =[]
+ self .cancellation_message_logged_this_session =False
+ self .favorite_scope_toggle_button =None
+ self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
+
+ self .manga_mode_checkbox =None
+
+ self .selected_cookie_filepath =None
+ self .retryable_failed_files_info =[]
+
+ self .is_paused =False
+ self .worker_to_gui_queue =queue .Queue ()
+ self .gui_update_timer =QTimer (self )
+ self .actual_gui_signals =PostProcessorSignals ()
+
+ self .worker_signals =PostProcessorSignals ()
+ self .prompt_mutex =QMutex ()
+ self ._add_character_response =None
+
+ self ._original_scan_content_tooltip =("If checked, the downloader will scan the HTML content of posts for image URLs (from
tags or direct links).\n"
+ "now This includes resolving relative paths from
tags to full URLs.\n"
+ "Relative paths in
tags (e.g., /data/image.jpg) will be resolved to full URLs.\n"
+ "Useful for cases where images are in the post description but not in the API's file/attachment list.")
+
+ 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 .manga_rename_toggle_button =None
+ self .favorite_mode_checkbox =None
+ self .url_or_placeholder_stack =None
+ self .url_input_widget =None
+ self .url_placeholder_widget =None
+ self .favorite_action_buttons_widget =None
+ self .favorite_mode_artists_button =None
+ self .favorite_mode_posts_button =None
+ self .standard_action_buttons_widget =None
+ self .bottom_action_buttons_stack =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 .log_verbosity_toggle_button =None
+
+ self .missed_character_log_output =None
+ self .log_view_stack =None
+ self .current_log_view ='progress'
+
+ self .link_search_input =None
+ self .link_search_button =None
+ self .export_links_button =None
+ self .radio_only_links =None
+ self .radio_only_archives =None
+ self .missed_title_key_terms_count ={}
+ self .missed_title_key_terms_examples ={}
+ self .logged_summary_for_key_term =set ()
+ self .STOP_WORDS =set (["a","an","the","is","was","were","of","for","with","in","on","at","by","to","and","or","but","i","you","he","she","it","we","they","my","your","his","her","its","our","their","com","net","org","www"])
+ self .already_logged_bold_key_terms =set ()
+ self .missed_key_terms_buffer =[]
+ self .char_filter_scope_toggle_button =None
+ self .skip_words_scope =SKIP_SCOPE_POSTS
+ self .char_filter_scope =CHAR_SCOPE_TITLE
+ self .manga_filename_style =self .settings .value (MANGA_FILENAME_STYLE_KEY ,STYLE_POST_TITLE ,type =str )
+ self .current_theme =self .settings .value (THEME_KEY ,"dark",type =str )
+ self .only_links_log_display_mode =LOG_DISPLAY_LINKS
+ self .mega_download_log_preserved_once =False
+ self .allow_multipart_download_setting =False
+ self .use_cookie_setting =False
+ self .scan_content_images_setting =self .settings .value (SCAN_CONTENT_IMAGES_KEY ,False ,type =bool )
+ self .cookie_text_setting =""
+ self .current_selected_language =self .settings .value (LANGUAGE_KEY ,"en",type =str )
+
+ print (f"ℹ️ Known.txt will be loaded/saved at: {self .config_file }")
+ try :
+ if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
+
+ base_dir_for_icon =sys ._MEIPASS
+ else :
+ app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+ icon_path_for_window =os .path .join (app_base_dir ,'assets','Kemono.ico')
+ if os .path .exists (icon_path_for_window ):
+ self .setWindowIcon (QIcon (icon_path_for_window ))
+ else :
+ self .log_signal .emit (f"⚠️ Main window icon 'assets/Kemono.ico' not found at {icon_path_for_window } (tried in DownloaderApp init)")
+ except Exception as e_icon_app :
+ self .log_signal .emit (f"❌ Error setting main window icon in DownloaderApp init: {e_icon_app }")
+
+ self .url_label_widget =None
+ self .download_location_label_widget =None
+
+ self .remove_from_filename_label_widget =None
+ self .skip_words_label_widget =None
+
+ self .setWindowTitle ("Kemono Downloader v5.5.0")
+
+ self .init_ui ()
+ self ._connect_signals ()
+ self .log_signal .emit ("ℹ️ Local API server functionality has been removed.")
+ self .log_signal .emit ("ℹ️ 'Skip Current File' button has been removed.")
+ if hasattr (self ,'character_input'):
+ self .character_input .setToolTip (self ._tr ("character_input_tooltip","Enter character names (comma-separated)..."))
+ 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 }'")
+ self .log_signal .emit (f"ℹ️ Character filter scope set to default: '{self .char_filter_scope }'")
+ self .log_signal .emit (f"ℹ️ Multi-part download defaults to: {'Enabled'if self .allow_multipart_download_setting else 'Disabled'}")
+ self .log_signal .emit (f"ℹ️ Cookie text defaults to: Empty on launch")
+ self .log_signal .emit (f"ℹ️ 'Use Cookie' setting defaults to: Disabled on launch")
+ self .log_signal .emit (f"ℹ️ Scan post content for images defaults to: {'Enabled'if self .scan_content_images_setting else 'Disabled'}")
+ self .log_signal .emit (f"ℹ️ Application language loaded: '{self .current_selected_language .upper ()}' (UI may not reflect this yet).")
+ self ._retranslate_main_ui ()
+ self ._load_persistent_history ()
+ self ._load_saved_download_location ()
+ self._update_button_states_and_connections() # Initial button state setup
+ self._check_for_interrupted_session()
+
+ def get_checkbox_map(self):
+ """Returns a mapping of checkbox attribute names to their corresponding settings key."""
+ return {
+ 'skip_zip_checkbox': 'skip_zip',
+ 'skip_rar_checkbox': 'skip_rar',
+ 'download_thumbnails_checkbox': 'download_thumbnails',
+ 'compress_images_checkbox': 'compress_images',
+ 'use_subfolders_checkbox': 'use_subfolders',
+ 'use_subfolder_per_post_checkbox': 'use_post_subfolders',
+ 'use_multithreading_checkbox': 'use_multithreading',
+ 'external_links_checkbox': 'show_external_links',
+ 'manga_mode_checkbox': 'manga_mode_active',
+ 'scan_content_images_checkbox': 'scan_content_for_images',
+ 'use_cookie_checkbox': 'use_cookie',
+ 'favorite_mode_checkbox': 'favorite_mode_active'
+ }
+
+ def _get_current_ui_settings_as_dict(self, api_url_override=None, output_dir_override=None):
+ """Gathers all relevant UI settings into a JSON-serializable dictionary."""
+ settings = {}
+
+ settings['api_url'] = api_url_override if api_url_override is not None else self.link_input.text().strip()
+ settings['output_dir'] = output_dir_override if output_dir_override is not None else self.dir_input.text().strip()
+ settings['character_filter_text'] = self.character_input.text().strip()
+ settings['skip_words_text'] = self.skip_words_input.text().strip()
+ settings['remove_words_text'] = self.remove_from_filename_input.text().strip()
+ settings['custom_folder_name'] = self.custom_folder_input.text().strip()
+ settings['cookie_text'] = self.cookie_text_input.text().strip()
+ if hasattr(self, 'manga_date_prefix_input'):
+ settings['manga_date_prefix'] = self.manga_date_prefix_input.text().strip()
+
+ try: settings['num_threads'] = int(self.thread_count_input.text().strip())
+ except (ValueError, AttributeError): settings['num_threads'] = 4
+ try: settings['start_page'] = int(self.start_page_input.text().strip()) if self.start_page_input.text().strip() else None
+ except (ValueError, AttributeError): settings['start_page'] = None
+ try: settings['end_page'] = int(self.end_page_input.text().strip()) if self.end_page_input.text().strip() else None
+ except (ValueError, AttributeError): settings['end_page'] = None
+
+ for checkbox_name, key in self.get_checkbox_map().items():
+ if checkbox := getattr(self, checkbox_name, None): settings[key] = checkbox.isChecked()
+
+ settings['filter_mode'] = self.get_filter_mode()
+ settings['only_links'] = self.radio_only_links.isChecked()
+
+ settings['skip_words_scope'] = self.skip_words_scope
+ settings['char_filter_scope'] = self.char_filter_scope
+ settings['manga_filename_style'] = self.manga_filename_style
+ settings['allow_multipart_download'] = self.allow_multipart_download_setting
+
+ return settings
+
+
+ def _tr (self ,key ,default_text =""):
+ """Helper to get translation based on current app language for the main window."""
+ if callable (get_translation ):
+ return get_translation (self .current_selected_language ,key ,default_text )
+ return default_text
+
+ def _load_saved_download_location (self ):
+ saved_location =self .settings .value (DOWNLOAD_LOCATION_KEY ,"",type =str )
+ if saved_location and os .path .isdir (saved_location ):
+ if hasattr (self ,'dir_input')and self .dir_input :
+ self .dir_input .setText (saved_location )
+ self .log_signal .emit (f"ℹ️ Loaded saved download location: {saved_location }")
+ else :
+ self .log_signal .emit (f"⚠️ Found saved download location '{saved_location }', but dir_input not ready.")
+ elif saved_location :
+ self .log_signal .emit (f"⚠️ Found saved download location '{saved_location }', but it's not a valid directory. Ignoring.")
+
+ def _check_for_interrupted_session(self):
+ """Checks for an incomplete session file on startup and prepares the UI for restore if found."""
+ if os.path.exists(self.session_file_path):
+ try:
+ with open(self.session_file_path, 'r', encoding='utf-8') as f:
+ session_data = json.load(f)
+
+ if "ui_settings" not in session_data or "download_state" not in session_data:
+ raise ValueError("Invalid session file structure.")
+
+ self.interrupted_session_data = session_data
+ self.log_signal.emit("ℹ️ Incomplete download session found. UI updated for restore.")
+ self._prepare_ui_for_restore()
+
+ except Exception as e:
+ self.log_signal.emit(f"❌ Error reading session file: {e}. Deleting corrupt session file.")
+ os.remove(self.session_file_path)
+ self.interrupted_session_data = None
+ self.is_restore_pending = False
+
+ def _prepare_ui_for_restore(self):
+ """Configures the UI to a 'restore pending' state."""
+ if not self.interrupted_session_data:
+ return
+
+ self.log_signal.emit(" UI updated for session restore.")
+ settings = self.interrupted_session_data.get("ui_settings", {})
+ self._load_ui_from_settings_dict(settings)
+
+ self.is_restore_pending = True
+ self._update_button_states_and_connections() # Update buttons for restore state, UI remains editable
+
+ def _clear_session_and_reset_ui(self):
+ """Clears the session file and resets the UI to its default state."""
+ self._clear_session_file()
+ self.interrupted_session_data = None
+ self.is_restore_pending = False
+ self._update_button_states_and_connections() # Ensure buttons are updated to idle state
+ self.reset_application_state()
+
+ def _clear_session_file(self):
+ """Safely deletes the session file."""
+ if os.path.exists(self.session_file_path):
+ try:
+ os.remove(self.session_file_path)
+ self.log_signal.emit("ℹ️ Interrupted session file cleared.")
+ except Exception as e:
+ self.log_signal.emit(f"❌ Failed to clear session file: {e}")
+
+ def _save_session_file(self, session_data):
+ """Safely saves the session data to the session file using an atomic write pattern."""
+ temp_session_file_path = self.session_file_path + ".tmp"
+ try:
+ with open(temp_session_file_path, 'w', encoding='utf-8') as f:
+ json.dump(session_data, f, indent=2)
+ os.replace(temp_session_file_path, self.session_file_path)
+ except Exception as e:
+ self.log_signal.emit(f"❌ Failed to save session state: {e}")
+ if os.path.exists(temp_session_file_path):
+ try:
+ os.remove(temp_session_file_path)
+ except Exception as e_rem:
+ self.log_signal.emit(f"❌ Failed to remove temp session file: {e_rem}")
+
+ 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).
+ """
+ # Disconnect all signals first to prevent multiple connections
+ try: self.download_btn.clicked.disconnect()
+ except TypeError: pass
+ try: self.pause_btn.clicked.disconnect()
+ except TypeError: pass
+ try: self.cancel_btn.clicked.disconnect()
+ except TypeError: pass
+
+ is_download_active = self._is_download_active()
+
+ if self.is_restore_pending:
+ # State: 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)
+ self.download_btn.setToolTip(self._tr("start_download_discard_tooltip", "Click to start a new download, discarding the previous session."))
+
+ self.pause_btn.setText(self._tr("restore_download_button_text", "🔄 Restore Download"))
+ self.pause_btn.setEnabled(True)
+ self.pause_btn.clicked.connect(self.restore_download)
+ self.pause_btn.setToolTip(self._tr("restore_download_button_tooltip", "Click to restore the interrupted download."))
+ self.cancel_btn.setEnabled(True)
+
+ self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
+ self.cancel_btn.setEnabled(False) # Nothing to cancel yet
+ 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)."))
+ elif is_download_active:
+ # State: Downloading / Paused
+ 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.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)
+ self.pause_btn.clicked.connect(self._handle_pause_resume_action)
+ self.pause_btn.setToolTip(self._tr("resume_download_button_tooltip", "Click to resume the download.") if self.is_paused else self._tr("pause_download_button_tooltip", "Click to pause the download."))
+
+ self.cancel_btn.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
+ self.cancel_btn.setEnabled(True)
+ self.cancel_btn.clicked.connect(self.cancel_download_button_action)
+ 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)."))
+ else:
+ # State: Idle (No download, no 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)
+
+ 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.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.setToolTip(self._tr("cancel_button_tooltip", "Click to cancel the ongoing download/extraction process and reset the UI fields (preserving URL and Directory)."))
+
+
+ def _retranslate_main_ui (self ):
+ """Retranslates static text elements in the main UI."""
+ if self .url_label_widget :
+ self .url_label_widget .setText (self ._tr ("creator_post_url_label","🔗 Kemono Creator/Post URL:"))
+ if self .download_location_label_widget :
+ self .download_location_label_widget .setText (self ._tr ("download_location_label","📁 Download Location:"))
+ if hasattr (self ,'character_label')and self .character_label :
+ self .character_label .setText (self ._tr ("filter_by_character_label","🎯 Filter by Character(s) (comma-separated):"))
+ if self .skip_words_label_widget :
+ self .skip_words_label_widget .setText (self ._tr ("skip_with_words_label","🚫 Skip with Words (comma-separated):"))
+ if self .remove_from_filename_label_widget :
+ self .remove_from_filename_label_widget .setText (self ._tr ("remove_words_from_name_label","✂️ Remove Words from name:"))
+ if hasattr (self ,'radio_all'):self .radio_all .setText (self ._tr ("filter_all_radio","All"))
+ if hasattr (self ,'radio_images'):self .radio_images .setText (self ._tr ("filter_images_radio","Images/GIFs"))
+ if hasattr (self ,'radio_videos'):self .radio_videos .setText (self ._tr ("filter_videos_radio","Videos"))
+ if hasattr (self ,'radio_only_archives'):self .radio_only_archives .setText (self ._tr ("filter_archives_radio","📦 Only Archives"))
+ if hasattr (self ,'radio_only_links'):self .radio_only_links .setText (self ._tr ("filter_links_radio","🔗 Only Links"))
+ if hasattr (self ,'radio_only_audio'):self .radio_only_audio .setText (self ._tr ("filter_audio_radio","🎧 Only Audio"))
+ if hasattr (self ,'favorite_mode_checkbox'):self .favorite_mode_checkbox .setText (self ._tr ("favorite_mode_checkbox_label","⭐ Favorite Mode"))
+ if hasattr (self ,'dir_button'):self .dir_button .setText (self ._tr ("browse_button_text","Browse..."))
+ self ._update_char_filter_scope_button_text ()
+ self ._update_skip_scope_button_text ()
+
+ if hasattr (self ,'skip_zip_checkbox'):self .skip_zip_checkbox .setText (self ._tr ("skip_zip_checkbox_label","Skip .zip"))
+ if hasattr (self ,'skip_rar_checkbox'):self .skip_rar_checkbox .setText (self ._tr ("skip_rar_checkbox_label","Skip .rar"))
+ if hasattr (self ,'download_thumbnails_checkbox'):self .download_thumbnails_checkbox .setText (self ._tr ("download_thumbnails_checkbox_label","Download Thumbnails Only"))
+ if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setText (self ._tr ("scan_content_images_checkbox_label","Scan Content for Images"))
+ if hasattr (self ,'compress_images_checkbox'):self .compress_images_checkbox .setText (self ._tr ("compress_images_checkbox_label","Compress to WebP"))
+ if hasattr (self ,'use_subfolders_checkbox'):self .use_subfolders_checkbox .setText (self ._tr ("separate_folders_checkbox_label","Separate Folders by Name/Title"))
+ if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .setText (self ._tr ("subfolder_per_post_checkbox_label","Subfolder per Post"))
+ if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setText (self ._tr ("use_cookie_checkbox_label","Use Cookie"))
+ if hasattr (self ,'use_multithreading_checkbox'):self .update_multithreading_label (self .thread_count_input .text ()if hasattr (self ,'thread_count_input')else "1")
+ if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setText (self ._tr ("show_external_links_checkbox_label","Show External Links in Log"))
+ if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setText (self ._tr ("manga_comic_mode_checkbox_label","Manga/Comic Mode"))
+ if hasattr (self ,'thread_count_label'):self .thread_count_label .setText (self ._tr ("threads_label","Threads:"))
+
+ if hasattr (self ,'character_input'):
+ self .character_input .setToolTip (self ._tr ("character_input_tooltip","Enter character names (comma-separated)..."))
+ if hasattr (self ,'download_btn'):self .download_btn .setToolTip (self ._tr ("start_download_button_tooltip","Click to start the download or link extraction process with the current settings."))
+
+
+
+
+
+ current_download_is_active =self ._is_download_active ()if hasattr (self ,'_is_download_active')else False
+ self .set_ui_enabled (not current_download_is_active )
+
+ if hasattr (self ,'known_chars_label'):self .known_chars_label .setText (self ._tr ("known_chars_label_text","🎭 Known Shows/Characters (for Folder Names):"))
+ if hasattr (self ,'open_known_txt_button'):self .open_known_txt_button .setText (self ._tr ("open_known_txt_button_text","Open Known.txt"));self .open_known_txt_button .setToolTip (self ._tr ("open_known_txt_button_tooltip","Open the 'Known.txt' file..."))
+ if hasattr (self ,'add_char_button'):self .add_char_button .setText (self ._tr ("add_char_button_text","➕ Add"));self .add_char_button .setToolTip (self ._tr ("add_char_button_tooltip","Add the name from the input field..."))
+ if hasattr (self ,'add_to_filter_button'):self .add_to_filter_button .setText (self ._tr ("add_to_filter_button_text","⤵️ Add to Filter"));self .add_to_filter_button .setToolTip (self ._tr ("add_to_filter_button_tooltip","Select names from 'Known Shows/Characters' list..."))
+ if hasattr (self ,'character_list'):
+ self .character_list .setToolTip (self ._tr ("known_chars_list_tooltip","This list contains names used for automatic folder creation..."))
+ if hasattr (self ,'delete_char_button'):self .delete_char_button .setText (self ._tr ("delete_char_button_text","🗑️ Delete Selected"));self .delete_char_button .setToolTip (self ._tr ("delete_char_button_tooltip","Delete the selected name(s)..."))
+
+ if hasattr (self ,'cancel_btn'):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)."))
+ if hasattr (self ,'error_btn'):self .error_btn .setText (self ._tr ("error_button_text","Error"));self .error_btn .setToolTip (self ._tr ("error_button_tooltip","View files skipped due to errors and optionally retry them."))
+ if hasattr (self ,'progress_log_label'):self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
+ if hasattr (self ,'reset_button'):self .reset_button .setText (self ._tr ("reset_button_text","🔄 Reset"));self .reset_button .setToolTip (self ._tr ("reset_button_tooltip","Reset all inputs and logs to default state (only when idle)."))
+ self ._update_multipart_toggle_button_text ()
+ if hasattr (self ,'progress_label')and not self ._is_download_active ():self .progress_label .setText (self ._tr ("progress_idle_text","Progress: Idle"))
+ if hasattr (self ,'favorite_mode_artists_button'):self .favorite_mode_artists_button .setText (self ._tr ("favorite_artists_button_text","🖼️ Favorite Artists"));self .favorite_mode_artists_button .setToolTip (self ._tr ("favorite_artists_button_tooltip","Browse and download from your favorite artists..."))
+ if hasattr (self ,'favorite_mode_posts_button'):self .favorite_mode_posts_button .setText (self ._tr ("favorite_posts_button_text","📄 Favorite Posts"));self .favorite_mode_posts_button .setToolTip (self ._tr ("favorite_posts_button_tooltip","Browse and download your favorite posts..."))
+ self ._update_favorite_scope_button_text ()
+ if hasattr (self ,'page_range_label'):self .page_range_label .setText (self ._tr ("page_range_label_text","Page Range:"))
+ if hasattr (self ,'start_page_input'):
+ self .start_page_input .setPlaceholderText (self ._tr ("start_page_input_placeholder","Start"))
+ self .start_page_input .setToolTip (self ._tr ("start_page_input_tooltip","For creator URLs: Specify the starting page number..."))
+ if hasattr (self ,'to_label'):self .to_label .setText (self ._tr ("page_range_to_label_text","to"))
+ if hasattr (self ,'end_page_input'):
+ self .end_page_input .setPlaceholderText (self ._tr ("end_page_input_placeholder","End"))
+ self .end_page_input .setToolTip (self ._tr ("end_page_input_tooltip","For creator URLs: Specify the ending page number..."))
+ if hasattr (self ,'fav_mode_active_label'):
+ self .fav_mode_active_label .setText (self ._tr ("fav_mode_active_label_text","⭐ Favorite Mode is active..."))
+ if hasattr (self ,'cookie_browse_button'):
+ self .cookie_browse_button .setToolTip (self ._tr ("cookie_browse_button_tooltip","Browse for a cookie file..."))
+ self ._update_manga_filename_style_button_text ()
+ if hasattr (self ,'export_links_button'):self .export_links_button .setText (self ._tr ("export_links_button_text","Export Links"))
+ if hasattr (self ,'download_extracted_links_button'):self .download_extracted_links_button .setText (self ._tr ("download_extracted_links_button_text","Download"))
+ self ._update_log_display_mode_button_text ()
+
+
+ if hasattr (self ,'radio_all'):self .radio_all .setToolTip (self ._tr ("radio_all_tooltip","Download all file types found in posts."))
+ if hasattr (self ,'radio_images'):self .radio_images .setToolTip (self ._tr ("radio_images_tooltip","Download only common image formats (JPG, PNG, GIF, WEBP, etc.)."))
+ if hasattr (self ,'radio_videos'):self .radio_videos .setToolTip (self ._tr ("radio_videos_tooltip","Download only common video formats (MP4, MKV, WEBM, MOV, etc.)."))
+ if hasattr (self ,'radio_only_archives'):self .radio_only_archives .setToolTip (self ._tr ("radio_only_archives_tooltip","Exclusively download .zip and .rar files. Other file-specific options are disabled."))
+ if hasattr (self ,'radio_only_audio'):self .radio_only_audio .setToolTip (self ._tr ("radio_only_audio_tooltip","Download only common audio formats (MP3, WAV, FLAC, etc.)."))
+ if hasattr (self ,'radio_only_links'):self .radio_only_links .setToolTip (self ._tr ("radio_only_links_tooltip","Extract and display external links from post descriptions instead of downloading files.\nDownload-related options will be disabled."))
+
+
+ if hasattr (self ,'use_subfolders_checkbox'):self .use_subfolders_checkbox .setToolTip (self ._tr ("use_subfolders_checkbox_tooltip","Create subfolders based on 'Filter by Character(s)' input..."))
+ if hasattr (self ,'use_subfolder_per_post_checkbox'):self .use_subfolder_per_post_checkbox .setToolTip (self ._tr ("use_subfolder_per_post_checkbox_tooltip","Creates a subfolder for each post..."))
+ if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setToolTip (self ._tr ("use_cookie_checkbox_tooltip","If checked, will attempt to use cookies..."))
+ if hasattr (self ,'use_multithreading_checkbox'):self .use_multithreading_checkbox .setToolTip (self ._tr ("use_multithreading_checkbox_tooltip","Enables concurrent operations..."))
+ if hasattr (self ,'thread_count_input'):self .thread_count_input .setToolTip (self ._tr ("thread_count_input_tooltip","Number of concurrent operations..."))
+ if hasattr (self ,'external_links_checkbox'):self .external_links_checkbox .setToolTip (self ._tr ("external_links_checkbox_tooltip","If checked, a secondary log panel appears..."))
+ if hasattr (self ,'manga_mode_checkbox'):self .manga_mode_checkbox .setToolTip (self ._tr ("manga_mode_checkbox_tooltip","Downloads posts from oldest to newest..."))
+
+ if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setToolTip (self ._tr ("scan_content_images_checkbox_tooltip",self ._original_scan_content_tooltip ))
+ if hasattr (self ,'download_thumbnails_checkbox'):self .download_thumbnails_checkbox .setToolTip (self ._tr ("download_thumbnails_checkbox_tooltip","Downloads small preview images..."))
+ if hasattr (self ,'skip_words_input'):
+ self .skip_words_input .setToolTip (self ._tr ("skip_words_input_tooltip",
+ ("Enter words, comma-separated, to skip downloading certain content (e.g., WIP, sketch, preview).\n\n"
+ "The 'Scope: [Type]' button next to this input cycles how this filter applies:\n"
+ "- Scope: Files: Skips individual files if their names contain any of these words.\n"
+ "- Scope: Posts: Skips entire posts if their titles contain any of these words.\n"
+ "- Scope: Both: Applies both (post title first, then individual files if post title is okay).")))
+ if hasattr (self ,'remove_from_filename_input'):
+ self .remove_from_filename_input .setToolTip (self ._tr ("remove_words_input_tooltip",
+ ("Enter words, comma-separated, to remove from downloaded filenames (case-insensitive).\n"
+ "Useful for cleaning up common prefixes/suffixes.\nExample: patreon, kemono, [HD], _final")))
+
+ if hasattr (self ,'link_input'):
+ self .link_input .setPlaceholderText (self ._tr ("link_input_placeholder_text","e.g., https://kemono.su/patreon/user/12345 or .../post/98765"))
+ self .link_input .setToolTip (self ._tr ("link_input_tooltip_text","Enter the full URL..."))
+ if hasattr (self ,'dir_input'):
+ self .dir_input .setPlaceholderText (self ._tr ("dir_input_placeholder_text","Select folder where downloads will be saved"))
+ self .dir_input .setToolTip (self ._tr ("dir_input_tooltip_text","Enter or browse to the main folder..."))
+ if hasattr (self ,'character_input'):
+ self .character_input .setPlaceholderText (self ._tr ("character_input_placeholder_text","e.g., Tifa, Aerith, (Cloud, Zack)"))
+ if hasattr (self ,'custom_folder_input'):
+ self .custom_folder_input .setPlaceholderText (self ._tr ("custom_folder_input_placeholder_text","Optional: Save this post to specific folder"))
+ self .custom_folder_input .setToolTip (self ._tr ("custom_folder_input_tooltip_text","If downloading a single post URL..."))
+ if hasattr (self ,'skip_words_input'):
+ self .skip_words_input .setPlaceholderText (self ._tr ("skip_words_input_placeholder_text","e.g., WM, WIP, sketch, preview"))
+ if hasattr (self ,'remove_from_filename_input'):
+ self .remove_from_filename_input .setPlaceholderText (self ._tr ("remove_from_filename_input_placeholder_text","e.g., patreon, HD"))
+ self ._update_cookie_input_placeholders_and_tooltips ()
+ if hasattr (self ,'character_search_input'):
+ self .character_search_input .setPlaceholderText (self ._tr ("character_search_input_placeholder_text","Search characters..."))
+ self .character_search_input .setToolTip (self ._tr ("character_search_input_tooltip_text","Type here to filter the list..."))
+ if hasattr (self ,'new_char_input'):
+ self .new_char_input .setPlaceholderText (self ._tr ("new_char_input_placeholder_text","Add new show/character name"))
+ self .new_char_input .setToolTip (self ._tr ("new_char_input_tooltip_text","Enter a new show, game, or character name..."))
+ if hasattr (self ,'link_search_input'):
+ self .link_search_input .setPlaceholderText (self ._tr ("link_search_input_placeholder_text","Search Links..."))
+ self .link_search_input .setToolTip (self ._tr ("link_search_input_tooltip_text","When in 'Only Links' mode..."))
+ if hasattr (self ,'manga_date_prefix_input'):
+ self .manga_date_prefix_input .setPlaceholderText (self ._tr ("manga_date_prefix_input_placeholder_text","Prefix for Manga Filenames"))
+ self .manga_date_prefix_input .setToolTip (self ._tr ("manga_date_prefix_input_tooltip_text","Optional prefix for 'Date Based'..."))
+ if hasattr (self ,'empty_popup_button'):self .empty_popup_button .setToolTip (self ._tr ("empty_popup_button_tooltip_text","Open Creator Selection..."))
+ if hasattr (self ,'known_names_help_button'):self .known_names_help_button .setToolTip (self ._tr ("known_names_help_button_tooltip_text","Open the application feature guide."))
+ if hasattr (self ,'future_settings_button'):self .future_settings_button .setToolTip (self ._tr ("future_settings_button_tooltip_text","Open application settings..."))
+ if hasattr (self ,'link_search_button'):self .link_search_button .setToolTip (self ._tr ("link_search_button_tooltip_text","Filter displayed links"))
+ def apply_theme (self ,theme_name ,initial_load =False ):
+ self .current_theme =theme_name
+ if not initial_load :
+ self .settings .setValue (THEME_KEY ,theme_name )
+ self .settings .sync ()
+
+ if theme_name =="dark":
+ self .setStyleSheet (self .get_dark_theme ())
+ if not initial_load :
+ self .log_signal .emit ("🎨 Switched to Dark Mode.")
+ else :
+ self .setStyleSheet ("")
+ if not initial_load :
+ self .log_signal .emit ("🎨 Switched to Light Mode.")
+ self .update ()
+
+ def _get_tooltip_for_character_input (self ):
+ return (
+ self ._tr ("character_input_tooltip","Default tooltip if translation fails.")
+ )
+ def _connect_signals (self ):
+ self .actual_gui_signals .progress_signal .connect (self .handle_main_log )
+ self .actual_gui_signals .file_progress_signal .connect (self .update_file_progress_display )
+ self .actual_gui_signals .missed_character_post_signal .connect (self .handle_missed_character_post )
+ self .actual_gui_signals .external_link_signal .connect (self .handle_external_link_signal )
+ self .actual_gui_signals .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
+ self .actual_gui_signals .file_download_status_signal .connect (lambda status :None )
+
+ if hasattr (self ,'character_input'):
+ self .character_input .textChanged .connect (self ._on_character_input_changed_live )
+ if hasattr (self ,'use_cookie_checkbox'):
+ self .use_cookie_checkbox .toggled .connect (self ._update_cookie_input_visibility )
+ if hasattr (self ,'link_input'):
+ self .link_input .textChanged .connect (self ._sync_queue_with_link_input )
+ if hasattr (self ,'cookie_browse_button'):
+ self .cookie_browse_button .clicked .connect (self ._browse_cookie_file )
+ if hasattr (self ,'cookie_text_input'):
+ 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 )
+ self .gui_update_timer .timeout .connect (self ._process_worker_queue )
+ self .gui_update_timer .start (100 )
+ self .log_signal .connect (self .handle_main_log )
+ 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 .post_processed_for_history_signal .connect (self ._add_to_history_candidates )
+ self .finished_signal .connect (self .download_finished )
+ 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_toggle_button :self .log_verbosity_toggle_button .clicked .connect (self .toggle_active_log_view )
+
+ if self .link_search_button :self .link_search_button .clicked .connect (self ._filter_links_log )
+ if self .link_search_input :
+ self .link_search_input .returnPressed .connect (self ._filter_links_log )
+ self .link_search_input .textChanged .connect (self ._filter_links_log )
+ if self .export_links_button :self .export_links_button .clicked .connect (self ._export_links_to_file )
+
+ if self .manga_mode_checkbox :self .manga_mode_checkbox .toggled .connect (self .update_ui_for_manga_mode )
+
+
+ if hasattr (self ,'download_extracted_links_button'):
+ self .download_extracted_links_button .clicked .connect (self ._show_download_extracted_links_dialog )
+
+ if hasattr (self ,'log_display_mode_toggle_button'):
+ self .log_display_mode_toggle_button .clicked .connect (self ._toggle_log_display_mode )
+
+ if self .manga_rename_toggle_button :self .manga_rename_toggle_button .clicked .connect (self ._toggle_manga_filename_style )
+
+ 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 )
+
+ if self .char_filter_scope_toggle_button :
+ self .char_filter_scope_toggle_button .clicked .connect (self ._cycle_char_filter_scope )
+
+ if hasattr (self ,'multipart_toggle_button'):self .multipart_toggle_button .clicked .connect (self ._toggle_multipart_mode )
+
+
+ if hasattr (self ,'favorite_mode_checkbox'):
+ self .favorite_mode_checkbox .toggled .connect (self ._handle_favorite_mode_toggle )
+
+ if hasattr (self ,'open_known_txt_button'):
+ self .open_known_txt_button .clicked .connect (self ._open_known_txt_file )
+
+ if hasattr (self ,'add_to_filter_button'):
+ self .add_to_filter_button .clicked .connect (self ._show_add_to_filter_dialog )
+ if hasattr (self ,'favorite_mode_artists_button'):
+ self .favorite_mode_artists_button .clicked .connect (self ._show_favorite_artists_dialog )
+ if hasattr (self ,'favorite_mode_posts_button'):
+ self .favorite_mode_posts_button .clicked .connect (self ._show_favorite_posts_dialog )
+ if hasattr (self ,'favorite_scope_toggle_button'):
+ self .favorite_scope_toggle_button .clicked .connect (self ._cycle_favorite_scope )
+ if hasattr (self ,'history_button'):
+ self .history_button .clicked .connect (self ._show_download_history_dialog )
+ if hasattr (self ,'error_btn'):
+ self .error_btn .clicked .connect (self ._show_error_files_dialog )
+
+ def _on_character_input_changed_live (self ,text ):
+ """
+ Called when the character input field text changes.
+ If a download is active (running or paused), this updates the dynamic filter holder.
+ """
+ if self ._is_download_active ():
+ QCoreApplication .processEvents ()
+ raw_character_filters_text =self .character_input .text ().strip ()
+ parsed_filters =self ._parse_character_filters (raw_character_filters_text )
+
+ self .dynamic_character_filter_holder .set_filters (parsed_filters )
+
+ def _parse_character_filters (self ,raw_text ):
+ """Helper to parse character filter string into list of objects."""
+ parsed_character_filter_objects =[]
+ if raw_text :
+ raw_parts =[]
+ current_part_buffer =""
+ in_group_parsing =False
+ for char_token in raw_text :
+ if char_token =='('and not in_group_parsing :
+ in_group_parsing =True
+ current_part_buffer +=char_token
+ elif char_token ==')'and in_group_parsing :
+ 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
+
+ is_tilde_group =part_str .startswith ("(")and part_str .endswith (")~")
+ is_standard_group_for_splitting =part_str .startswith ("(")and part_str .endswith (")")and not is_tilde_group
+
+ if is_tilde_group :
+ group_content_str =part_str [1 :-2 ].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 ,"is_group":True ,"aliases":aliases_in_group })
+ elif is_standard_group_for_splitting :
+ 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 ,
+ "is_group":True ,
+ "aliases":aliases_in_group ,
+ "components_are_distinct_for_known_txt":True
+ })
+ else :
+ parsed_character_filter_objects .append ({"name":part_str ,"is_group":False ,"aliases":[part_str ],"components_are_distinct_for_known_txt":False })
+ return parsed_character_filter_objects
+
+ def _process_worker_queue (self ):
+ """Processes messages from the worker queue and emits Qt signals from the GUI thread."""
+ while not self .worker_to_gui_queue .empty ():
+ try :
+ item =self .worker_to_gui_queue .get_nowait ()
+ signal_type =item .get ('type')
+ payload =item .get ('payload',tuple ())
+
+ if signal_type =='progress':
+ self .actual_gui_signals .progress_signal .emit (*payload )
+ elif signal_type =='file_download_status':
+ self .actual_gui_signals .file_download_status_signal .emit (*payload )
+ elif signal_type =='external_link':
+ self .actual_gui_signals .external_link_signal .emit (*payload )
+ elif signal_type =='file_progress':
+ self .actual_gui_signals .file_progress_signal .emit (*payload )
+ elif signal_type =='missed_character_post':
+ self .actual_gui_signals .missed_character_post_signal .emit (*payload )
+ elif signal_type =='file_successfully_downloaded':
+ self ._handle_actual_file_downloaded (payload [0 ]if payload else {})
+ elif signal_type =='file_successfully_downloaded':
+ self ._handle_file_successfully_downloaded (payload [0 ])
+ else :
+ self .log_signal .emit (f"⚠️ Unknown signal type from worker queue: {signal_type }")
+ self .worker_to_gui_queue .task_done ()
+ except queue .Empty :
+ break
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error processing worker queue: {e }")
+
+ def load_known_names_from_util (self ):
+ global KNOWN_NAMES
+ if os .path .exists (self .config_file ):
+ parsed_known_objects =[]
+ try :
+ with open (self .config_file ,'r',encoding ='utf-8')as f :
+ for line_num ,line in enumerate (f ,1 ):
+ line =line .strip ()
+ if not line :continue
+
+ if line .startswith ("(")and line .endswith (")"):
+ content =line [1 :-1 ].strip ()
+ parts =[p .strip ()for p in content .split (',')if p .strip ()]
+ if parts :
+ folder_name_raw =content .replace (',',' ')
+ folder_name_cleaned =clean_folder_name (folder_name_raw )
+
+ unique_aliases_set ={p for p in parts }
+ final_aliases_list =sorted (list (unique_aliases_set ),key =str .lower )
+
+ if not folder_name_cleaned :
+ if hasattr (self ,'log_signal'):self .log_signal .emit (f"⚠️ Group resulted in empty folder name after cleaning in Known.txt on line {line_num }: '{line }'. Skipping entry.")
+ continue
+
+ parsed_known_objects .append ({
+ "name":folder_name_cleaned ,
+ "is_group":True ,
+ "aliases":final_aliases_list
+ })
+ else :
+ if hasattr (self ,'log_signal'):self .log_signal .emit (f"⚠️ Empty group found in Known.txt on line {line_num }: '{line }'")
+ else :
+ parsed_known_objects .append ({
+ "name":line ,
+ "is_group":False ,
+ "aliases":[line ]
+ })
+ parsed_known_objects .sort (key =lambda x :x ["name"].lower ())
+ KNOWN_NAMES [:]=parsed_known_objects
+ log_msg =f"ℹ️ Loaded {len (KNOWN_NAMES )} known entries from {self .config_file }"
+ except Exception as e :
+ 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 }")
+ KNOWN_NAMES [:]=[]
+ else :
+ self .character_input .setToolTip ("Names, comma-separated. Group aliases: (alias1, alias2, alias3) becomes folder name 'alias1 alias2 alias3' (after cleaning).\nAll names in the group are used as aliases for matching.\nE.g., yor, (Boa, Hancock, Snake Princess)")
+ log_msg =f"ℹ️ Config file '{self .config_file }' not found. It will be created on save."
+ KNOWN_NAMES [:]=[]
+
+ if hasattr (self ,'log_signal'):self .log_signal .emit (log_msg )
+
+ if hasattr (self ,'character_list'):
+ self .character_list .clear ()
+ if not KNOWN_NAMES :
+ self .log_signal .emit ("ℹ️ 'Known.txt' is empty or was not found. No default entries will be added.")
+
+ self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
+
+ def save_known_names (self ):
+ global KNOWN_NAMES
+ try :
+ with open (self .config_file ,'w',encoding ='utf-8')as f :
+ for entry in KNOWN_NAMES :
+ if entry ["is_group"]:
+ f .write (f"({', '.join (sorted (entry ['aliases'],key =str .lower ))})\n")
+ else :
+ f .write (entry ["name"]+'\n')
+ if hasattr (self ,'log_signal'):self .log_signal .emit (f"💾 Saved {len (KNOWN_NAMES )} known entries to {self .config_file }")
+ except Exception as e :
+ log_msg =f"❌ Error saving config '{self .config_file }': {e }"
+ if hasattr (self ,'log_signal'):self .log_signal .emit (log_msg )
+ QMessageBox .warning (self ,"Config Save Error",f"Could not save list to {self .config_file }:\n{e }")
+
+ def closeEvent (self ,event ):
+ self .save_known_names ()
+ self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
+ self .settings .setValue (ALLOW_MULTIPART_DOWNLOAD_KEY ,self .allow_multipart_download_setting )
+ self .settings .setValue (COOKIE_TEXT_KEY ,self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "")
+ self .settings .setValue (SCAN_CONTENT_IMAGES_KEY ,self .scan_content_images_checkbox .isChecked ()if hasattr (self ,'scan_content_images_checkbox')else False )
+ self .settings .setValue (USE_COOKIE_KEY ,self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False )
+ self .settings .setValue (THEME_KEY ,self .current_theme )
+ self .settings .setValue (LANGUAGE_KEY ,self .current_selected_language )
+ self .settings .sync ()
+ self ._save_persistent_history ()
+
+ should_exit =True
+ is_downloading =self ._is_download_active ()
+
+ if is_downloading :
+ reply =QMessageBox .question (self ,"Confirm Exit",
+ "Download in progress. Are you sure you want to exit and cancel?",
+ QMessageBox .Yes |QMessageBox .No ,QMessageBox .No )
+ if reply ==QMessageBox .Yes :
+ self .log_signal .emit ("⚠️ Cancelling active download due to application exit...")
+ 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.")
+ if self .download_thread and self .download_thread .isRunning ():
+ self .log_signal .emit (" Waiting for single download thread to finish...")
+ self .download_thread .wait (3000 )
+ if self .download_thread .isRunning ():
+ self .log_signal .emit (" ⚠️ Single download thread did not terminate gracefully.")
+
+ if self .thread_pool :
+ self .log_signal .emit (" Shutting down thread pool (waiting for completion)...")
+ self .thread_pool .shutdown (wait =True ,cancel_futures =True )
+ self .log_signal .emit (" Thread pool shutdown complete.")
+ self .thread_pool =None
+ self .log_signal .emit (" Cancellation for exit complete.")
+ else :
+ should_exit =False
+ self .log_signal .emit ("ℹ️ Application exit cancelled.")
+ event .ignore ()
+ return
+
+ if should_exit :
+ self .log_signal .emit ("ℹ️ Application closing.")
+ if self .thread_pool :
+ self .log_signal .emit (" Final thread pool check: Shutting down...")
+ self .cancellation_event .set ()
+ self .thread_pool .shutdown (wait =True ,cancel_futures =True )
+ self .thread_pool =None
+ self .log_signal .emit ("👋 Exiting application.")
+ event .accept ()
+
+
+ def _request_restart_application (self ):
+ self .log_signal .emit ("🔄 Application restart requested by user for language change.")
+ self ._restart_pending =True
+ self .close ()
+
+ def _do_actual_restart (self ):
+ try :
+ self .log_signal .emit (" Performing application restart...")
+ python_executable =sys .executable
+ script_args =sys .argv
+
+
+ if getattr (sys ,'frozen',False ):
+
+
+
+ QProcess .startDetached (python_executable ,script_args [1 :])
+ else :
+
+
+ QProcess .startDetached (python_executable ,script_args )
+
+ QCoreApplication .instance ().quit ()
+ except Exception as e :
+ self .log_signal .emit (f"❌ CRITICAL: Failed to start new application instance: {e }")
+ QMessageBox .critical (self ,"Restart Failed",
+ f"Could not automatically restart the application: {e }\n\nPlease restart it manually.")
+
+
+ def init_ui (self ):
+ self .main_splitter =QSplitter (Qt .Horizontal )
+ 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 )
+ self .apply_theme (self .current_theme ,initial_load =True )
+
+ self .url_input_widget =QWidget ()
+ url_input_layout =QHBoxLayout (self .url_input_widget )
+ url_input_layout .setContentsMargins (0 ,0 ,0 ,0 )
+
+ self .url_label_widget =QLabel ()
+ url_input_layout .addWidget (self .url_label_widget )
+ self .link_input =QLineEdit ()
+ self .link_input .setPlaceholderText ("e.g., https://kemono.su/patreon/user/12345 or .../post/98765")
+ self .link_input .textChanged .connect (self .update_custom_folder_visibility )
+ url_input_layout .addWidget (self .link_input ,1 )
+ self .empty_popup_button =QPushButton ("🎨")
+ self .empty_popup_button .setStyleSheet ("padding: 4px 6px;")
+ self .empty_popup_button .clicked .connect (self ._show_empty_popup )
+ url_input_layout .addWidget (self .empty_popup_button )
+
+ self .page_range_label =QLabel (self ._tr ("page_range_label_text","Page Range:"))
+ self .page_range_label .setStyleSheet ("font-weight: bold; padding-left: 10px;")
+ url_input_layout .addWidget (self .page_range_label )
+ self .start_page_input =QLineEdit ()
+ self .start_page_input .setPlaceholderText (self ._tr ("start_page_input_placeholder","Start"))
+ self .start_page_input .setFixedWidth (50 )
+ self .start_page_input .setValidator (QIntValidator (1 ,99999 ))
+ url_input_layout .addWidget (self .start_page_input )
+ self .to_label =QLabel (self ._tr ("page_range_to_label_text","to"))
+ url_input_layout .addWidget (self .to_label )
+ self .end_page_input =QLineEdit ()
+ self .end_page_input .setPlaceholderText (self ._tr ("end_page_input_placeholder","End"))
+ self .end_page_input .setFixedWidth (50 )
+ self .end_page_input .setToolTip (self ._tr ("end_page_input_tooltip","For creator URLs: Specify the ending page number..."))
+ self .end_page_input .setValidator (QIntValidator (1 ,99999 ))
+ url_input_layout .addWidget (self .end_page_input )
+
+ self .url_placeholder_widget =QWidget ()
+ placeholder_layout =QHBoxLayout (self .url_placeholder_widget )
+ placeholder_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ self .fav_mode_active_label =QLabel (self ._tr ("fav_mode_active_label_text","⭐ Favorite Mode is active..."))
+ self .fav_mode_active_label .setAlignment (Qt .AlignCenter )
+ placeholder_layout .addWidget (self .fav_mode_active_label )
+
+ self .url_or_placeholder_stack =QStackedWidget ()
+ self .url_or_placeholder_stack .addWidget (self .url_input_widget )
+ self .url_or_placeholder_stack .addWidget (self .url_placeholder_widget )
+ left_layout .addWidget (self .url_or_placeholder_stack )
+
+ self .favorite_action_buttons_widget =QWidget ()
+ favorite_buttons_layout =QHBoxLayout (self .favorite_action_buttons_widget )
+ favorite_buttons_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ self .favorite_mode_artists_button =QPushButton ("🖼️ Favorite Artists")
+ self .favorite_mode_artists_button .setToolTip ("Browse and download from your favorite artists on Kemono.su.")
+ self .favorite_mode_artists_button .setStyleSheet ("padding: 4px 12px;")
+ self .favorite_mode_artists_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred )
+ self .favorite_mode_posts_button =QPushButton ("📄 Favorite Posts")
+ self .favorite_mode_posts_button .setToolTip ("Browse and download your favorite posts from Kemono.su.")
+ self .favorite_mode_posts_button .setStyleSheet ("padding: 4px 12px;")
+ self .favorite_mode_posts_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred )
+
+ self .favorite_scope_toggle_button =QPushButton ()
+ self .favorite_scope_toggle_button .setStyleSheet ("padding: 4px 10px;")
+ self .favorite_scope_toggle_button .setSizePolicy (QSizePolicy .Expanding ,QSizePolicy .Preferred )
+
+ favorite_buttons_layout .addWidget (self .favorite_mode_artists_button )
+ favorite_buttons_layout .addWidget (self .favorite_mode_posts_button )
+ favorite_buttons_layout .addWidget (self .favorite_scope_toggle_button )
+
+
+ self .download_location_label_widget =QLabel ()
+ left_layout .addWidget (self .download_location_label_widget )
+ self .dir_input =QLineEdit ()
+ self .dir_input .setPlaceholderText ("Select folder where downloads will be saved")
+ self .dir_button =QPushButton ("Browse...")
+ self .dir_button .setStyleSheet ("padding: 4px 10px;")
+ self .dir_button .clicked .connect (self .browse_directory )
+ dir_layout =QHBoxLayout ()
+ dir_layout .addWidget (self .dir_input ,1 )
+ dir_layout .addWidget (self .dir_button )
+ left_layout .addLayout (dir_layout )
+
+
+ self .filters_and_custom_folder_container_widget =QWidget ()
+ filters_and_custom_folder_layout =QHBoxLayout (self .filters_and_custom_folder_container_widget )
+ filters_and_custom_folder_layout .setContentsMargins (0 ,5 ,0 ,0 )
+ filters_and_custom_folder_layout .setSpacing (10 )
+
+ self .character_filter_widget =QWidget ()
+ character_filter_v_layout =QVBoxLayout (self .character_filter_widget )
+ character_filter_v_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ character_filter_v_layout .setSpacing (2 )
+
+ self .character_label =QLabel ("🎯 Filter by Character(s) (comma-separated):")
+ 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 )
+
+ self .character_input =QLineEdit ()
+ self .character_input .setPlaceholderText ("e.g., Tifa, Aerith, (Cloud, Zack)")
+ 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 .setStyleSheet ("padding: 4px 10px;")
+ self .char_filter_scope_toggle_button .setMinimumWidth (100 )
+ char_input_and_button_layout .addWidget (self .char_filter_scope_toggle_button ,1 )
+
+ character_filter_v_layout .addLayout (char_input_and_button_layout )
+
+
+ self .custom_folder_widget =QWidget ()
+ custom_folder_v_layout =QVBoxLayout (self .custom_folder_widget )
+ custom_folder_v_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ 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 )
+ self .custom_folder_widget .setVisible (False )
+
+ filters_and_custom_folder_layout .addWidget (self .character_filter_widget ,1 )
+ filters_and_custom_folder_layout .addWidget (self .custom_folder_widget ,1 )
+
+ left_layout .addWidget (self .filters_and_custom_folder_container_widget )
+ word_manipulation_container_widget =QWidget ()
+ word_manipulation_outer_layout =QHBoxLayout (word_manipulation_container_widget )
+ word_manipulation_outer_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ word_manipulation_outer_layout .setSpacing (15 )
+ skip_words_widget =QWidget ()
+ skip_words_vertical_layout =QVBoxLayout (skip_words_widget )
+ skip_words_vertical_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ skip_words_vertical_layout .setSpacing (2 )
+
+ self .skip_words_label_widget =QLabel ()
+ skip_words_vertical_layout .addWidget (self .skip_words_label_widget )
+
+ skip_input_and_button_layout =QHBoxLayout ()
+ skip_input_and_button_layout =QHBoxLayout ()
+ skip_input_and_button_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ skip_input_and_button_layout .setSpacing (10 )
+ self .skip_words_input =QLineEdit ()
+ self .skip_words_input .setPlaceholderText ("e.g., WM, WIP, sketch, preview")
+ skip_input_and_button_layout .addWidget (self .skip_words_input ,1 )
+
+ self .skip_scope_toggle_button =QPushButton ()
+ self ._update_skip_scope_button_text ()
+ self .skip_scope_toggle_button .setStyleSheet ("padding: 4px 10px;")
+ self .skip_scope_toggle_button .setMinimumWidth (100 )
+ skip_input_and_button_layout .addWidget (self .skip_scope_toggle_button ,0 )
+ skip_words_vertical_layout .addLayout (skip_input_and_button_layout )
+ word_manipulation_outer_layout .addWidget (skip_words_widget ,7 )
+ remove_words_widget =QWidget ()
+ remove_words_vertical_layout =QVBoxLayout (remove_words_widget )
+ remove_words_vertical_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ remove_words_vertical_layout .setSpacing (2 )
+ self .remove_from_filename_label_widget =QLabel ()
+ remove_words_vertical_layout .addWidget (self .remove_from_filename_label_widget )
+ self .remove_from_filename_input =QLineEdit ()
+ self .remove_from_filename_input .setPlaceholderText ("e.g., patreon, HD")
+ remove_words_vertical_layout .addWidget (self .remove_from_filename_input )
+ word_manipulation_outer_layout .addWidget (remove_words_widget ,3 )
+
+ left_layout .addWidget (word_manipulation_container_widget )
+
+
+ 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 )
+ self .radio_all =QRadioButton ("All")
+ self .radio_images =QRadioButton ("Images/GIFs")
+ self .radio_videos =QRadioButton ("Videos")
+ self .radio_only_archives =QRadioButton ("📦 Only Archives")
+ self .radio_only_audio =QRadioButton ("🎧 Only Audio")
+ self .radio_only_links =QRadioButton ("🔗 Only Links")
+ self .radio_all .setChecked (True )
+ self .radio_group .addButton (self .radio_all )
+ self .radio_group .addButton (self .radio_images )
+ self .radio_group .addButton (self .radio_videos )
+ self .radio_group .addButton (self .radio_only_archives )
+ self .radio_group .addButton (self .radio_only_audio )
+ self .radio_group .addButton (self .radio_only_links )
+ radio_button_layout .addWidget (self .radio_all )
+ radio_button_layout .addWidget (self .radio_images )
+ radio_button_layout .addWidget (self .radio_videos )
+ radio_button_layout .addWidget (self .radio_only_archives )
+ radio_button_layout .addWidget (self .radio_only_audio )
+ file_filter_layout .addLayout (radio_button_layout )
+ left_layout .addLayout (file_filter_layout )
+
+ self .favorite_mode_checkbox =QCheckBox ()
+ self .favorite_mode_checkbox .setChecked (False )
+ radio_button_layout .addWidget (self .radio_only_links )
+ radio_button_layout .addWidget (self .favorite_mode_checkbox )
+ radio_button_layout .addStretch (1 )
+ checkboxes_group_layout =QVBoxLayout ()
+ checkboxes_group_layout .setSpacing (10 )
+
+ row1_layout =QHBoxLayout ()
+ row1_layout .setSpacing (10 )
+ self .skip_zip_checkbox =QCheckBox ("Skip .zip")
+ self .skip_zip_checkbox .setChecked (True )
+ row1_layout .addWidget (self .skip_zip_checkbox )
+ self .skip_rar_checkbox =QCheckBox ("Skip .rar")
+ self .skip_rar_checkbox .setChecked (True )
+ row1_layout .addWidget (self .skip_rar_checkbox )
+ self .download_thumbnails_checkbox =QCheckBox ("Download Thumbnails Only")
+ self .download_thumbnails_checkbox .setChecked (False )
+ row1_layout .addWidget (self .download_thumbnails_checkbox )
+
+ self .scan_content_images_checkbox =QCheckBox ("Scan Content for Images")
+ self .scan_content_images_checkbox .setChecked (self .scan_content_images_setting )
+ row1_layout .addWidget (self .scan_content_images_checkbox )
+
+ self .compress_images_checkbox =QCheckBox ("Compress to WebP")
+ self .compress_images_checkbox .setChecked (False )
+ self .compress_images_checkbox .setToolTip ("Compress images > 1.5MB to WebP format (requires Pillow).")
+ row1_layout .addWidget (self .compress_images_checkbox )
+
+ row1_layout .addStretch (1 )
+ checkboxes_group_layout .addLayout (row1_layout )
+
+ advanced_settings_label =QLabel ("⚙️ Advanced Settings:")
+ checkboxes_group_layout .addWidget (advanced_settings_label )
+
+ advanced_row1_layout =QHBoxLayout ()
+ advanced_row1_layout .setSpacing (10 )
+ self .use_subfolders_checkbox =QCheckBox ("Separate Folders by Name/Title")
+ self .use_subfolders_checkbox .setChecked (True )
+ self .use_subfolders_checkbox .toggled .connect (self .update_ui_for_subfolders )
+ advanced_row1_layout .addWidget (self .use_subfolders_checkbox )
+ self .use_subfolder_per_post_checkbox =QCheckBox ("Subfolder per Post")
+ self .use_subfolder_per_post_checkbox .setChecked (False )
+ self .use_subfolder_per_post_checkbox .toggled .connect (self .update_ui_for_subfolders )
+ advanced_row1_layout .addWidget (self .use_subfolder_per_post_checkbox )
+
+ self .use_cookie_checkbox =QCheckBox ("Use Cookie")
+ self .use_cookie_checkbox .setChecked (self .use_cookie_setting )
+
+ self .cookie_text_input =QLineEdit ()
+ self .cookie_text_input .setPlaceholderText ("if no Select cookies.txt)")
+ self .cookie_text_input .setMinimumHeight (28 )
+ self .cookie_text_input .setText (self .cookie_text_setting )
+
+ advanced_row1_layout .addWidget (self .use_cookie_checkbox )
+ advanced_row1_layout .addWidget (self .cookie_text_input ,2 )
+
+ self .cookie_browse_button =QPushButton ("Browse...")
+ self .cookie_browse_button .setFixedWidth (80 )
+ self .cookie_browse_button .setStyleSheet ("padding: 4px 8px;")
+ advanced_row1_layout .addWidget (self .cookie_browse_button )
+
+ advanced_row1_layout .addStretch (1 )
+ checkboxes_group_layout .addLayout (advanced_row1_layout )
+
+ advanced_row2_layout =QHBoxLayout ()
+ advanced_row2_layout .setSpacing (10 )
+
+ multithreading_layout =QHBoxLayout ()
+ multithreading_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ self .use_multithreading_checkbox =QCheckBox ("Use Multithreading")
+ self .use_multithreading_checkbox .setChecked (True )
+ multithreading_layout .addWidget (self .use_multithreading_checkbox )
+ self .thread_count_label =QLabel ("Threads:")
+ multithreading_layout .addWidget (self .thread_count_label )
+ self .thread_count_input =QLineEdit ()
+ self .thread_count_input .setFixedWidth (40 )
+ self .thread_count_input .setText ("4")
+ self .thread_count_input .setValidator (QIntValidator (1 ,MAX_THREADS ))
+ multithreading_layout .addWidget (self .thread_count_input )
+ advanced_row2_layout .addLayout (multithreading_layout )
+
+ self .external_links_checkbox =QCheckBox ("Show External Links in Log")
+ self .external_links_checkbox .setChecked (False )
+ advanced_row2_layout .addWidget (self .external_links_checkbox )
+
+ self .manga_mode_checkbox =QCheckBox ("Manga/Comic Mode")
+ self .manga_mode_checkbox .setChecked (False )
+
+ advanced_row2_layout .addWidget (self .manga_mode_checkbox )
+
+
+ advanced_row2_layout .addStretch (1 )
+ checkboxes_group_layout .addLayout (advanced_row2_layout )
+ left_layout .addLayout (checkboxes_group_layout )
+
+ self .standard_action_buttons_widget =QWidget ()
+ btn_layout =QHBoxLayout ()
+ btn_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ btn_layout .setSpacing (10 )
+ self .download_btn =QPushButton ("⬇️ Start Download")
+ self .download_btn .setStyleSheet ("padding: 4px 12px; font-weight: bold;")
+ self .download_btn .clicked .connect (self .start_download )
+
+ self .pause_btn =QPushButton ("⏸️ Pause Download")
+ self .pause_btn .setEnabled (False )
+ self .pause_btn .setStyleSheet ("padding: 4px 12px;")
+ self .pause_btn .clicked .connect (self ._handle_pause_resume_action )
+
+ self .cancel_btn =QPushButton ("❌ Cancel & Reset UI")
+
+ self .cancel_btn .setEnabled (False )
+ self .cancel_btn .setStyleSheet ("padding: 4px 12px;")
+ self .cancel_btn .clicked .connect (self .cancel_download_button_action )
+
+ self .error_btn =QPushButton ("Error")
+ self .error_btn .setToolTip ("View error details (functionality TBD).")
+ self .error_btn .setStyleSheet ("padding: 4px 8px;")
+ self .error_btn .setEnabled (True )
+ btn_layout .addWidget (self .download_btn )
+ btn_layout .addWidget (self .pause_btn )
+ btn_layout .addWidget (self .cancel_btn )
+ btn_layout .addWidget (self .error_btn )
+ self .standard_action_buttons_widget .setLayout (btn_layout )
+
+ self .bottom_action_buttons_stack =QStackedWidget ()
+ self .bottom_action_buttons_stack .addWidget (self .standard_action_buttons_widget )
+ self .bottom_action_buttons_stack .addWidget (self .favorite_action_buttons_widget )
+ left_layout .addWidget (self .bottom_action_buttons_stack )
+ left_layout .addSpacing (10 )
+
+
+ known_chars_label_layout =QHBoxLayout ()
+ known_chars_label_layout .setSpacing (10 )
+ self .known_chars_label =QLabel ("🎭 Known Shows/Characters (for Folder Names):")
+ known_chars_label_layout .addWidget (self .known_chars_label )
+ self .open_known_txt_button =QPushButton ("Open Known.txt")
+ self .open_known_txt_button .setStyleSheet ("padding: 4px 8px;")
+ self .open_known_txt_button .setFixedWidth (120 )
+ known_chars_label_layout .addWidget (self .open_known_txt_button )
+ self .character_search_input =QLineEdit ()
+ self .character_search_input .setPlaceholderText ("Search characters...")
+ known_chars_label_layout .addWidget (self .character_search_input ,1 )
+ left_layout .addLayout (known_chars_label_layout )
+
+ self .character_list =QListWidget ()
+ self .character_list .setSelectionMode (QListWidget .ExtendedSelection )
+ left_layout .addWidget (self .character_list ,1 )
+
+ char_manage_layout =QHBoxLayout ()
+ char_manage_layout .setSpacing (10 )
+ self .new_char_input =QLineEdit ()
+ self .new_char_input .setPlaceholderText ("Add new show/character name")
+ self .new_char_input .setStyleSheet ("padding: 3px 5px;")
+
+ self .add_char_button =QPushButton ("➕ Add")
+ self .add_char_button .setStyleSheet ("padding: 4px 10px;")
+
+ self .add_to_filter_button =QPushButton ("⤵️ Add to Filter")
+ self .add_to_filter_button .setToolTip ("Select names from 'Known Shows/Characters' list to add to the 'Filter by Character(s)' field above.")
+ self .add_to_filter_button .setStyleSheet ("padding: 4px 10px;")
+
+ self .delete_char_button =QPushButton ("🗑️ Delete Selected")
+ self .delete_char_button .setToolTip ("Delete the selected name(s) from the 'Known Shows/Characters' list.")
+ self .delete_char_button .setStyleSheet ("padding: 4px 10px;")
+
+ self .add_char_button .clicked .connect (self ._handle_ui_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 )
+ char_manage_layout .addWidget (self .add_char_button ,0 )
+
+ self .known_names_help_button =QPushButton ("?")
+ self .known_names_help_button .setFixedWidth (35 )
+ self .known_names_help_button .setStyleSheet ("padding: 4px 6px;")
+ self .known_names_help_button .clicked .connect (self ._show_feature_guide )
+
+ self .history_button =QPushButton ("📜")
+ self .history_button .setFixedWidth (35 )
+ self .history_button .setStyleSheet ("padding: 4px 6px;")
+ self .history_button .setToolTip (self ._tr ("history_button_tooltip_text","View download history"))
+
+ self .future_settings_button =QPushButton ("⚙️")
+ self .future_settings_button .setFixedWidth (35 )
+ self .future_settings_button .setStyleSheet ("padding: 4px 6px;")
+ self .future_settings_button .clicked .connect (self ._show_future_settings_dialog )
+ char_manage_layout .addWidget (self .add_to_filter_button ,1 )
+ char_manage_layout .addWidget (self .delete_char_button ,1 )
+ char_manage_layout .addWidget (self .known_names_help_button ,0 )
+ char_manage_layout .addWidget (self .history_button ,0 )
+ char_manage_layout .addWidget (self .future_settings_button ,0 )
+ left_layout .addLayout (char_manage_layout )
+ left_layout .addStretch (0 )
+
+ log_title_layout =QHBoxLayout ()
+ self .progress_log_label =QLabel ("📜 Progress Log:")
+ log_title_layout .addWidget (self .progress_log_label )
+ log_title_layout .addStretch (1 )
+
+ self .link_search_input =QLineEdit ()
+ self .link_search_input .setPlaceholderText ("Search Links...")
+ self .link_search_input .setVisible (False )
+
+ log_title_layout .addWidget (self .link_search_input )
+ self .link_search_button =QPushButton ("🔍")
+ self .link_search_button .setVisible (False )
+ self .link_search_button .setFixedWidth (30 )
+ self .link_search_button .setStyleSheet ("padding: 4px 4px;")
+ log_title_layout .addWidget (self .link_search_button )
+
+ self .manga_rename_toggle_button =QPushButton ()
+ self .manga_rename_toggle_button .setVisible (False )
+ self .manga_rename_toggle_button .setFixedWidth (140 )
+ self .manga_rename_toggle_button .setStyleSheet ("padding: 4px 8px;")
+ self ._update_manga_filename_style_button_text ()
+ log_title_layout .addWidget (self .manga_rename_toggle_button )
+ self .manga_date_prefix_input =QLineEdit ()
+ self .manga_date_prefix_input .setPlaceholderText ("Prefix for Manga Filenames")
+ self .manga_date_prefix_input .setVisible (False )
+
+ log_title_layout .addWidget (self .manga_date_prefix_input )
+
+ self .multipart_toggle_button =QPushButton ()
+ self .multipart_toggle_button .setToolTip ("Toggle between Multi-part and Single-stream downloads for large files.")
+ self .multipart_toggle_button .setFixedWidth (130 )
+ self .multipart_toggle_button .setStyleSheet ("padding: 4px 8px;")
+ self ._update_multipart_toggle_button_text ()
+ log_title_layout .addWidget (self .multipart_toggle_button )
+
+ self .EYE_ICON ="\U0001F441"
+ self .CLOSED_EYE_ICON ="\U0001F648"
+ self .log_verbosity_toggle_button =QPushButton (self .EYE_ICON )
+ self .log_verbosity_toggle_button .setFixedWidth (45 )
+ self .log_verbosity_toggle_button .setStyleSheet ("font-size: 11pt; padding: 4px 2px;")
+ log_title_layout .addWidget (self .log_verbosity_toggle_button )
+
+ self .reset_button =QPushButton ("🔄 Reset")
+ self .reset_button .setFixedWidth (80 )
+ self .reset_button .setStyleSheet ("padding: 4px 8px;")
+ log_title_layout .addWidget (self .reset_button )
+ right_layout .addLayout (log_title_layout )
+
+ self .log_splitter =QSplitter (Qt .Vertical )
+
+ self .log_view_stack =QStackedWidget ()
+
+ self .main_log_output =QTextEdit ()
+ self .main_log_output .setReadOnly (True )
+ self .main_log_output .setLineWrapMode (QTextEdit .NoWrap )
+ self .log_view_stack .addWidget (self .main_log_output )
+
+ self .missed_character_log_output =QTextEdit ()
+ self .missed_character_log_output .setReadOnly (True )
+ self .missed_character_log_output .setLineWrapMode (QTextEdit .NoWrap )
+ self .log_view_stack .addWidget (self .missed_character_log_output )
+
+ self .external_log_output =QTextEdit ()
+ self .external_log_output .setReadOnly (True )
+ self .external_log_output .setLineWrapMode (QTextEdit .NoWrap )
+ self .external_log_output .hide ()
+
+ self .log_splitter .addWidget (self .log_view_stack )
+ 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 )
+ self .export_links_button =QPushButton (self ._tr ("export_links_button_text","Export Links"))
+ self .export_links_button .setFixedWidth (100 )
+ self .export_links_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;")
+ self .export_links_button .setEnabled (False )
+ self .export_links_button .setVisible (False )
+ export_button_layout .addWidget (self .export_links_button )
+
+ self .download_extracted_links_button =QPushButton (self ._tr ("download_extracted_links_button_text","Download"))
+ self .download_extracted_links_button .setFixedWidth (100 )
+ self .download_extracted_links_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;")
+ self .download_extracted_links_button .setEnabled (False )
+ self .download_extracted_links_button .setVisible (False )
+ export_button_layout .addWidget (self .download_extracted_links_button )
+ self .log_display_mode_toggle_button =QPushButton ()
+ self .log_display_mode_toggle_button .setFixedWidth (120 )
+ self .log_display_mode_toggle_button .setStyleSheet ("padding: 4px 8px; margin-top: 5px;")
+ self .log_display_mode_toggle_button .setVisible (False )
+ export_button_layout .addWidget (self .log_display_mode_toggle_button )
+ right_layout .addLayout (export_button_layout )
+
+
+ self .progress_label =QLabel ("Progress: Idle")
+ self .progress_label .setStyleSheet ("padding-top: 5px; font-style: italic;")
+ right_layout .addWidget (self .progress_label )
+ self .file_progress_label =QLabel ("")
+ self .file_progress_label .setToolTip ("Shows the progress of individual file downloads, including speed and size.")
+ self .file_progress_label .setWordWrap (True )
+ self .file_progress_label .setStyleSheet ("padding-top: 2px; font-style: italic; color: #A0A0A0;")
+ right_layout .addWidget (self .file_progress_label )
+
+
+ self .main_splitter .addWidget (left_panel_widget )
+ self .main_splitter .addWidget (right_panel_widget )
+
+ if self .width ()==0 or self .height ()==0 :
+ initial_width =1024
+ else :
+ initial_width =self .width ()
+ left_width =int (initial_width *0.35 )
+ right_width =initial_width -left_width
+ self .main_splitter .setSizes ([left_width ,right_width ])
+
+ top_level_layout =QHBoxLayout (self )
+ top_level_layout .setContentsMargins (0 ,0 ,0 ,0 )
+ top_level_layout .addWidget (self .main_splitter )
+
+ self .update_ui_for_subfolders (self .use_subfolders_checkbox .isChecked ())
+ self .update_external_links_setting (self .external_links_checkbox .isChecked ())
+ self .update_multithreading_label (self .thread_count_input .text ())
+ self .update_page_range_enabled_state ()
+ if self .manga_mode_checkbox :
+ self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ())
+ 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 ))
+
+ self ._load_creator_name_cache_from_json ()
+ self .load_known_names_from_util ()
+ self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False )
+ 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 ()
+ self ._update_multithreading_for_date_mode ()
+ if hasattr (self ,'download_thumbnails_checkbox'):
+ self ._handle_thumbnail_mode_change (self .download_thumbnails_checkbox .isChecked ())
+ if hasattr (self ,'favorite_mode_checkbox'):
+
+ self ._handle_favorite_mode_toggle (False )
+
+ def _load_persistent_history (self ):
+ """Loads download history from a persistent file."""
+ self .log_signal .emit (f"📜 Attempting to load history from: {self .persistent_history_file }")
+ if os .path .exists (self .persistent_history_file ):
+ try :
+ with open (self .persistent_history_file ,'r',encoding ='utf-8')as f :
+ loaded_data =json .load (f )
+
+ if isinstance (loaded_data ,dict ):
+ self .last_downloaded_files_details .clear ()
+ self .last_downloaded_files_details .extend (loaded_data .get ("last_downloaded_files",[]))
+ self .final_download_history_entries =loaded_data .get ("first_processed_posts",[])
+ self .log_signal .emit (f"✅ Loaded {len (self .last_downloaded_files_details )} last downloaded files and {len (self .final_download_history_entries )} first processed posts from persistent history.")
+ elif loaded_data is None and os .path .getsize (self .persistent_history_file )==0 :
+ self .log_signal .emit (f"ℹ️ Persistent history file is empty. Initializing with empty history.")
+ self .final_download_history_entries =[]
+ self .last_downloaded_files_details .clear ()
+ elif isinstance(loaded_data, list): # Handle old format where only first_processed_posts was saved
+ self.log_signal.emit("⚠️ Persistent history file is in old format (only first_processed_posts). Converting to new format.")
+ self.final_download_history_entries = loaded_data
+ self.last_downloaded_files_details.clear()
+ self._save_persistent_history() # Save in new format immediately
+ else :
+ self .log_signal .emit (f"⚠️ Persistent history file has incorrect format. Expected list, got {type (loaded_history )}. Ignoring.")
+ self .final_download_history_entries =[]
+ except json .JSONDecodeError :
+ self .log_signal .emit (f"⚠️ Error decoding persistent history file. It might be corrupted. Ignoring.")
+ self .final_download_history_entries =[]
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error loading persistent history: {e }")
+ self .final_download_history_entries =[]
+ else :
+ self .log_signal .emit (f"⚠️ Persistent history file NOT FOUND at: {self .persistent_history_file }. Starting with empty history.")
+ self .final_download_history_entries =[]
+ self ._save_persistent_history ()
+
+ def _save_persistent_history (self ):
+ """Saves download history to a persistent file."""
+ self .log_signal .emit (f"📜 Attempting to save history to: {self .persistent_history_file }")
+ try :
+ history_dir =os .path .dirname (self .persistent_history_file )
+ self .log_signal .emit (f" History directory: {history_dir }")
+ if not os .path .exists (history_dir ):
+ os .makedirs (history_dir ,exist_ok =True )
+ self .log_signal .emit (f" Created history directory: {history_dir }")
+
+ history_data = {
+ "last_downloaded_files": list(self.last_downloaded_files_details),
+ "first_processed_posts": self.final_download_history_entries
+ }
+ with open (self .persistent_history_file ,'w',encoding ='utf-8')as f :
+ json .dump (history_data ,f ,indent =2 )
+ self .log_signal .emit (f"✅ Saved {len (self .final_download_history_entries )} history entries to: {self .persistent_history_file }")
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error saving persistent history to {self .persistent_history_file }: {e }")
+ def _load_creator_name_cache_from_json (self ):
+ """Loads creator id-name-service mappings from creators.json into self.creator_name_cache."""
+ self .log_signal .emit ("ℹ️ Attempting to load creators.json for creator name cache.")
+
+ if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
+ base_path_for_creators =sys ._MEIPASS
+ else :
+ base_path_for_creators =self .app_base_dir
+
+ creators_file_path =os .path .join (base_path_for_creators ,"creators.json")
+
+ if not os .path .exists (creators_file_path ):
+ self .log_signal .emit (f"⚠️ 'creators.json' not found at {creators_file_path }. Creator name cache will be empty.")
+ self .creator_name_cache .clear ()
+ return
+
+ try :
+ with open (creators_file_path ,'r',encoding ='utf-8')as f :
+ loaded_data =json .load (f )
+
+ creators_list =[]
+ if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ):
+ creators_list =loaded_data [0 ]
+ elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ):
+ creators_list =loaded_data
+ else :
+ self .log_signal .emit (f"⚠️ 'creators.json' has an unexpected format. Creator name cache may be incomplete.")
+
+ for creator_data in creators_list :
+ creator_id =creator_data .get ("id")
+ name =creator_data .get ("name")
+ service =creator_data .get ("service")
+ if creator_id and name and service :
+ self .creator_name_cache [(service .lower (),str (creator_id ))]=name
+ self .log_signal .emit (f"✅ Successfully loaded {len (self .creator_name_cache )} creator names into cache from 'creators.json'.")
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error loading 'creators.json' for name cache: {e }")
+ self .creator_name_cache .clear ()
+
+ def _show_download_history_dialog (self ):
+ """Shows the dialog with the finalized download history."""
+ last_3_downloaded =list (self .last_downloaded_files_details )
+ first_processed =self .final_download_history_entries
+
+ if not last_3_downloaded and not first_processed :
+ QMessageBox .information (
+ self ,
+ self ._tr ("download_history_dialog_title_empty","Download History (Empty)"),
+ self ._tr ("no_download_history_header","No Downloads Yet")
+ )
+ return
+
+ dialog =DownloadHistoryDialog (last_3_downloaded ,first_processed ,self ,self )
+ dialog .exec_ ()
+
+ def _handle_actual_file_downloaded (self ,file_details_dict ):
+ """Handles a successfully downloaded file for the 'last 3 downloaded' history."""
+ if not file_details_dict :
+ return
+ file_details_dict ['download_timestamp']=time .time ()
+ creator_key =(file_details_dict .get ('service','').lower (),str (file_details_dict .get ('user_id','')))
+ file_details_dict ['creator_display_name']=self .creator_name_cache .get (creator_key ,file_details_dict .get ('folder_context_name','Unknown Creator/Series'))
+ self .last_downloaded_files_details .append (file_details_dict )
+
+
+ def _handle_file_successfully_downloaded (self ,history_entry_dict ):
+ """Handles a successfully downloaded file for history logging."""
+ if len (self .download_history_log )>=self .download_history_log .maxlen :
+ self .download_history_log .popleft ()
+ self .download_history_log .append (history_entry_dict )
+
+
+ def _handle_actual_file_downloaded (self ,file_details_dict ):
+ """Handles a successfully downloaded file for the 'last 3 downloaded' history."""
+ if not file_details_dict :
+ return
+
+ file_details_dict ['download_timestamp']=time .time ()
+
+
+ creator_key =(
+ file_details_dict .get ('service','').lower (),
+ str (file_details_dict .get ('user_id',''))
+ )
+ creator_display_name =self .creator_name_cache .get (creator_key ,file_details_dict .get ('folder_context_name','Unknown Creator'))
+ file_details_dict ['creator_display_name']=creator_display_name
+
+ self .last_downloaded_files_details .append (file_details_dict )
+
+
+ def _handle_favorite_mode_toggle (self ,checked ):
+ if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
+ return
+
+ self ._handle_favorite_mode_toggle (self .favorite_mode_checkbox .isChecked ())
+ self ._update_favorite_scope_button_text ()
+ if hasattr (self ,'link_input'):
+ self .last_link_input_text_for_queue_sync =self .link_input .text ()
+
+ def _update_download_extracted_links_button_state (self ):
+ if hasattr (self ,'download_extracted_links_button')and self .download_extracted_links_button :
+ is_only_links =self .radio_only_links and self .radio_only_links .isChecked ()
+ if not is_only_links :
+ self .download_extracted_links_button .setEnabled (False )
+ return
+
+ supported_platforms_for_button ={'mega','google drive','dropbox'}
+ has_supported_links =any (
+ link_info [3 ].lower ()in supported_platforms_for_button for link_info in self .extracted_links_cache
+ )
+ self .download_extracted_links_button .setEnabled (is_only_links and has_supported_links )
+
+ def _show_download_extracted_links_dialog (self ):
+ """Shows the placeholder dialog for downloading extracted links."""
+ if not (self .radio_only_links and self .radio_only_links .isChecked ()):
+ self .log_signal .emit ("ℹ️ Download extracted links button clicked, but not in 'Only Links' mode.")
+ return
+
+ supported_platforms ={'mega','google drive','dropbox'}
+ links_to_show_in_dialog =[]
+ for link_data_tuple in self .extracted_links_cache :
+ platform =link_data_tuple [3 ].lower ()
+ if platform in supported_platforms :
+ links_to_show_in_dialog .append ({
+ 'title':link_data_tuple [0 ],
+ 'link_text':link_data_tuple [1 ],
+ 'url':link_data_tuple [2 ],
+ 'platform':platform ,
+ 'key':link_data_tuple [4 ]
+ })
+
+ if not links_to_show_in_dialog :
+ QMessageBox .information (self ,"No Supported Links","No Mega, Google Drive, or Dropbox links were found in the extracted links.")
+ return
+
+ dialog =DownloadExtractedLinksDialog (links_to_show_in_dialog ,self ,self )
+ dialog .download_requested .connect (self ._handle_extracted_links_download_request )
+ dialog .exec_ ()
+
+ def _handle_extracted_links_download_request (self ,selected_links_info ):
+ if not selected_links_info :
+ self .log_signal .emit ("ℹ️ No links selected for download from dialog.")
+ return
+
+
+ if self .radio_only_links and self .radio_only_links .isChecked ()and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
+ self .main_log_output .clear ()
+ self .log_signal .emit ("ℹ️ Displaying Mega download progress (extracted links hidden)...")
+ self .mega_download_log_preserved_once =False
+
+ current_main_dir =self .dir_input .text ().strip ()
+ download_dir_for_mega =""
+
+ if current_main_dir and os .path .isdir (current_main_dir ):
+ download_dir_for_mega =current_main_dir
+ self .log_signal .emit (f"ℹ️ Using existing main download location for external links: {download_dir_for_mega }")
+ else :
+ if not current_main_dir :
+ self .log_signal .emit ("ℹ️ Main download location is empty. Prompting for download folder.")
+ else :
+ self .log_signal .emit (
+ f"⚠️ Main download location '{current_main_dir }' is not a valid directory. Prompting for download folder.")
+
+
+ suggestion_path =current_main_dir if current_main_dir else QStandardPaths .writableLocation (QStandardPaths .DownloadLocation )
+
+ chosen_dir =QFileDialog .getExistingDirectory (
+ self ,
+ self ._tr ("select_download_folder_mega_dialog_title","Select Download Folder for External Links"),
+ suggestion_path ,
+ options =QFileDialog .ShowDirsOnly |QFileDialog .DontUseNativeDialog
+ )
+
+ if not chosen_dir :
+ self .log_signal .emit ("ℹ️ External links download cancelled - no download directory selected from prompt.")
+ return
+ download_dir_for_mega =chosen_dir
+
+
+ self .log_signal .emit (f"ℹ️ Preparing to download {len (selected_links_info )} selected external link(s) to: {download_dir_for_mega }")
+ if not os .path .exists (download_dir_for_mega ):
+ self .log_signal .emit (f"❌ Critical Error: Selected download directory '{download_dir_for_mega }' does not exist.")
+ return
+
+
+ tasks_for_thread =selected_links_info
+
+ if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
+ QMessageBox .warning (self ,"Busy","Another external link download is already in progress.")
+ return
+
+ self .external_link_download_thread =ExternalLinkDownloadThread (
+ tasks_for_thread ,
+ download_dir_for_mega ,
+ self .log_signal .emit ,
+ self
+ )
+ self .external_link_download_thread .finished .connect (self ._on_external_link_download_thread_finished )
+
+ self .external_link_download_thread .progress_signal .connect (self .handle_main_log )
+ self .external_link_download_thread .file_complete_signal .connect (self ._on_single_external_file_complete )
+
+
+
+ self .set_ui_enabled (False )
+
+ self .progress_label .setText (self ._tr ("progress_processing_post_text","Progress: Processing post {processed_posts}...").format (processed_posts =f"External Links (0/{len (tasks_for_thread )})"))
+ self .external_link_download_thread .start ()
+
+ def _on_external_link_download_thread_finished (self ):
+ self .log_signal .emit ("✅ External link download thread finished.")
+ self .progress_label .setText (f"{self ._tr ('status_completed','Completed')}: External link downloads. {self ._tr ('ready_for_new_task_text','Ready for new task.')}")
+
+ self .mega_download_log_preserved_once =True
+ self .log_signal .emit ("INTERNAL: mega_download_log_preserved_once SET to True.")
+
+ if self .radio_only_links and self .radio_only_links .isChecked ():
+ self .log_signal .emit (HTML_PREFIX +"
--- End of Mega Download Log ---
")
+
+
+
+ self .set_ui_enabled (True )
+
+
+
+ if self .mega_download_log_preserved_once :
+ self .mega_download_log_preserved_once =False
+ self .log_signal .emit ("INTERNAL: mega_download_log_preserved_once RESET to False.")
+
+ if self .external_link_download_thread :
+ self .external_link_download_thread .deleteLater ()
+ self .external_link_download_thread =None
+
+ def _on_single_external_file_complete (self ,url ,success ):
+
+
+ pass
+ def _show_future_settings_dialog (self ):
+ """Shows the placeholder dialog for future settings."""
+ dialog =FutureSettingsDialog (self )
+ dialog =FutureSettingsDialog (self ,self )
+ dialog .exec_ ()
+
+ def _sync_queue_with_link_input (self ,current_text ):
+ """
+ Synchronizes the favorite_download_queue with the link_input text.
+ Removes creators from the queue if their names are removed from the input field.
+ Only affects items added via 'creator_popup_selection'.
+ """
+ if not self .favorite_download_queue :
+ self .last_link_input_text_for_queue_sync =current_text
+ return
+
+ current_names_in_input ={name .strip ().lower ()for name in current_text .split (',')if name .strip ()}
+
+ queue_copy =list (self .favorite_download_queue )
+ removed_count =0
+
+ for item in queue_copy :
+ if item .get ('type')=='creator_popup_selection':
+ item_name_lower =item .get ('name','').lower ()
+ if item_name_lower and item_name_lower not in current_names_in_input :
+ try :
+ self .favorite_download_queue .remove (item )
+ self .log_signal .emit (f"ℹ️ Creator '{item .get ('name')}' removed from download queue due to removal from URL input.")
+ removed_count +=1
+ except ValueError :
+ self .log_signal .emit (f"⚠️ Tried to remove '{item .get ('name')}' from queue, but it was not found (sync).")
+
+ self .last_link_input_text_for_queue_sync =current_text
+
+ def _browse_cookie_file (self ):
+ """Opens a file dialog to select a cookie file."""
+ start_dir =QStandardPaths .writableLocation (QStandardPaths .DownloadLocation )
+ if not start_dir :
+ start_dir =os .path .dirname (self .config_file )
+
+ filepath ,_ =QFileDialog .getOpenFileName (self ,"Select Cookie File",start_dir ,"Text files (*.txt);;All files (*)")
+ if filepath :
+ self .selected_cookie_filepath =filepath
+ self .log_signal .emit (f"ℹ️ Selected cookie file: {filepath }")
+ if hasattr (self ,'cookie_text_input'):
+ self .cookie_text_input .blockSignals (True )
+ self .cookie_text_input .setText (filepath )
+ self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip_file_selected","Using selected cookie file: {filepath}").format (filepath =filepath ))
+ self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_with_file_selected_text","Using selected cookie file (see Browse...)"))
+ self .cookie_text_input .setReadOnly (True )
+ self .cookie_text_input .setPlaceholderText ("")
+ self .cookie_text_input .blockSignals (False )
+
+ def _update_cookie_input_placeholders_and_tooltips (self ):
+ if hasattr (self ,'cookie_text_input'):
+ if self .selected_cookie_filepath :
+ self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_with_file_selected_text","Using selected cookie file..."))
+ self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip_file_selected","Using selected cookie file: {filepath}").format (filepath =self .selected_cookie_filepath ))
+ else :
+ self .cookie_text_input .setPlaceholderText (self ._tr ("cookie_text_input_placeholder_no_file_selected_text","Cookie string (if no cookies.txt selected)"))
+ self .cookie_text_input .setToolTip (self ._tr ("cookie_text_input_tooltip","Enter your cookie string directly..."))
+ self .cookie_text_input .setReadOnly (True )
+ self .cookie_text_input .setPlaceholderText ("")
+ self .cookie_text_input .blockSignals (False )
+
+ def _center_on_screen (self ):
+ """Centers the widget on the screen."""
+ try :
+ primary_screen =QApplication .primaryScreen ()
+ if not primary_screen :
+ screens =QApplication .screens ()
+ if not screens :return
+ primary_screen =screens [0 ]
+
+ available_geo =primary_screen .availableGeometry ()
+ widget_geo =self .frameGeometry ()
+
+ x =available_geo .x ()+(available_geo .width ()-widget_geo .width ())//2
+ y =available_geo .y ()+(available_geo .height ()-widget_geo .height ())//2
+ self .move (x ,y )
+ except Exception as e :
+ self .log_signal .emit (f"⚠️ Error centering window: {e }")
+
+ def _handle_cookie_text_manual_change (self ,text ):
+ """Handles manual changes to the cookie text input, especially clearing a browsed path."""
+ if not hasattr (self ,'cookie_text_input')or not hasattr (self ,'use_cookie_checkbox'):
+ return
+ if self .selected_cookie_filepath and not text .strip ()and self .use_cookie_checkbox .isChecked ():
+ self .selected_cookie_filepath =None
+ self .cookie_text_input .setReadOnly (False )
+ self ._update_cookie_input_placeholders_and_tooltips ()
+ self .log_signal .emit ("ℹ️ Browsed cookie file path cleared from input. Switched to manual cookie string mode.")
+
+
+ def get_dark_theme (self ):
+ return """
+ 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;
+ font-family: Consolas, Courier New, monospace; font-size: 9.5pt; }
+ QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 6px 12px; border-radius: 4px; min-height: 22px; }
+ QPushButton:hover { background-color: #656565; border: 1px solid #7A7A7A; }
+ QPushButton:pressed { background-color: #4A4A4A; }
+ QPushButton:disabled { background-color: #404040; color: #888; border-color: #555; }
+ QLabel { font-weight: bold; padding-top: 4px; padding-bottom: 2px; color: #C0C0C0; }
+ QRadioButton, QCheckBox { spacing: 5px; color: #E0E0E0; padding-top: 4px; padding-bottom: 4px; }
+ QRadioButton::indicator, QCheckBox::indicator { width: 14px; height: 14px; }
+ QListWidget { alternate-background-color: #353535; border: 1px solid #5A5A5A; }
+ QListWidget::item:selected { background-color: #007ACC; color: #FFFFFF; }
+ QToolTip { background-color: #4A4A4A; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 4px; border-radius: 3px; }
+ QSplitter::handle { background-color: #5A5A5A; }
+ QSplitter::handle:horizontal { width: 5px; }
+ QSplitter::handle:vertical { height: 5px; }
+ QFrame[frameShape="4"], QFrame[frameShape="5"] {
+ border: 1px solid #4A4A4A;
+ border-radius: 3px;
+ }
+ """
+
+ def browse_directory (self ):
+ initial_dir_text =self .dir_input .text ()
+ start_path =""
+ if initial_dir_text and os .path .isdir (initial_dir_text ):
+ start_path =initial_dir_text
+ else :
+ home_location =QStandardPaths .writableLocation (QStandardPaths .HomeLocation )
+ documents_location =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation )
+ if home_location and os .path .isdir (home_location ):
+ start_path =home_location
+ elif documents_location and os .path .isdir (documents_location ):
+ start_path =documents_location
+
+ self .log_signal .emit (f"ℹ️ Opening folder dialog. Suggested start path: '{start_path }'")
+
+ try :
+ folder =QFileDialog .getExistingDirectory (
+ self ,
+ "Select Download Folder",
+ start_path ,
+ options =QFileDialog .DontUseNativeDialog |QFileDialog .ShowDirsOnly
+ )
+
+ if folder :
+ self .dir_input .setText (folder )
+ self .log_signal .emit (f"ℹ️ Folder selected: {folder }")
+ else :
+ self .log_signal .emit (f"ℹ️ Folder selection cancelled by user.")
+ except RuntimeError as e :
+ self .log_signal .emit (f"❌ RuntimeError opening folder dialog: {e }. This might indicate a deeper Qt or system issue.")
+ QMessageBox .critical (self ,"Dialog Error",f"A runtime error occurred while trying to open the folder dialog: {e }")
+ except Exception as e :
+ self .log_signal .emit (f"❌ Unexpected error opening folder dialog: {e }\n{traceback .format_exc (limit =3 )}")
+ QMessageBox .critical (self ,"Dialog Error",f"An unexpected error occurred with the folder selection dialog: {e }")
+
+ def handle_main_log (self ,message ):
+ is_html_message =message .startswith (HTML_PREFIX )
+ display_message =message
+ use_html =False
+
+ if is_html_message :
+ display_message =message [len (HTML_PREFIX ):]
+ use_html =True
+
+ try :
+ safe_message =str (display_message ).replace ('\x00','[NULL]')
+ if use_html :
+ self .main_log_output .insertHtml (safe_message )
+ else :
+ self .main_log_output .append (safe_message )
+
+ scrollbar =self .main_log_output .verticalScrollBar ()
+ if scrollbar .value ()>=scrollbar .maximum ()-30 :
+ scrollbar .setValue (scrollbar .maximum ())
+ except Exception as e :
+ print (f"GUI Main Log Error: {e }\nOriginal Message: {message }")
+ def _extract_key_term_from_title (self ,title ):
+ if not title :
+ return None
+ title_cleaned =re .sub (r'\[.*?\]','',title )
+ title_cleaned =re .sub (r'\(.*?\)','',title_cleaned )
+ title_cleaned =title_cleaned .strip ()
+ word_matches =list (re .finditer (r'\b[a-zA-Z][a-zA-Z0-9_-]*\b',title_cleaned ))
+
+ capitalized_candidates =[]
+ for match in word_matches :
+ word =match .group (0 )
+ if word .istitle ()and word .lower ()not in self .STOP_WORDS and len (word )>2 :
+ if not (len (word )>3 and word .isupper ()):
+ capitalized_candidates .append ({'text':word ,'len':len (word ),'pos':match .start ()})
+
+ if capitalized_candidates :
+ capitalized_candidates .sort (key =lambda x :(x ['len'],x ['pos']),reverse =True )
+ return capitalized_candidates [0 ]['text']
+ non_capitalized_words_info =[]
+ for match in word_matches :
+ word =match .group (0 )
+ if word .lower ()not in self .STOP_WORDS and len (word )>3 :
+ non_capitalized_words_info .append ({'text':word ,'len':len (word ),'pos':match .start ()})
+
+ if non_capitalized_words_info :
+ non_capitalized_words_info .sort (key =lambda x :(x ['len'],x ['pos']),reverse =True )
+ return non_capitalized_words_info [0 ]['text']
+
+ return None
+
+ def handle_missed_character_post (self ,post_title ,reason ):
+ if self .missed_character_log_output :
+ key_term =self ._extract_key_term_from_title (post_title )
+
+ if key_term :
+ normalized_key_term =key_term .lower ()
+ if normalized_key_term not in self .already_logged_bold_key_terms :
+ self .already_logged_bold_key_terms .add (normalized_key_term )
+ self .missed_key_terms_buffer .append (key_term )
+ self ._refresh_missed_character_log ()
+ else :
+ print (f"Debug (Missed Char Log): Title='{post_title }', Reason='{reason }'")
+
+ def _refresh_missed_character_log (self ):
+ if self .missed_character_log_output :
+ self .missed_character_log_output .clear ()
+ sorted_terms =sorted (self .missed_key_terms_buffer ,key =str .lower )
+ separator_line ="-"*40
+
+ for term in sorted_terms :
+ display_term =term .capitalize ()
+
+ self .missed_character_log_output .append (separator_line )
+ self .missed_character_log_output .append (f'{display_term }
')
+ self .missed_character_log_output .append (separator_line )
+ self .missed_character_log_output .append ("")
+
+ scrollbar =self .missed_character_log_output .verticalScrollBar ()
+ scrollbar .setValue (0 )
+
+ def _is_download_active (self ):
+ single_thread_active =self .download_thread and self .download_thread .isRunning ()
+ fetcher_active =hasattr (self ,'is_fetcher_thread_running')and self .is_fetcher_thread_running
+ pool_has_active_tasks =self .thread_pool is not None and any (not f .done ()for f in self .active_futures if f is not None )
+ retry_pool_active =hasattr (self ,'retry_thread_pool')and self .retry_thread_pool is not None and hasattr (self ,'active_retry_futures')and any (not f .done ()for f in self .active_retry_futures if f is not None )
+
+
+ external_dl_thread_active =hasattr (self ,'external_link_download_thread')and self .external_link_download_thread is not None and self .external_link_download_thread .isRunning ()
+
+ return single_thread_active or fetcher_active or pool_has_active_tasks or retry_pool_active or external_dl_thread_active
+
+ def handle_external_link_signal (self ,post_title ,link_text ,link_url ,platform ,decryption_key ):
+ link_data =(post_title ,link_text ,link_url ,platform ,decryption_key )
+ self .external_link_queue .append (link_data )
+ if self .radio_only_links and self .radio_only_links .isChecked ():
+ self .extracted_links_cache .append (link_data )
+ self ._update_download_extracted_links_button_state ()
+
+ is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
+ 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 ):
+ self ._is_processing_external_link_queue =False
+ if self .external_link_queue :
+ QTimer .singleShot (0 ,self ._try_process_next_external_link )
+ return
+
+
+ if link_data not in self .extracted_links_cache :
+ self .extracted_links_cache .append (link_data )
+
+ def _try_process_next_external_link (self ):
+ if self ._is_processing_external_link_queue or not self .external_link_queue :
+ return
+
+ is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
+ 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 ):
+ self ._is_processing_external_link_queue =False
+ if self .external_link_queue :
+ QTimer .singleShot (0 ,self ._try_process_next_external_link )
+ return
+
+ self ._is_processing_external_link_queue =True
+ link_data =self .external_link_queue .popleft ()
+
+ if is_only_links_mode :
+ QTimer .singleShot (0 ,lambda data =link_data :self ._display_and_schedule_next (data ))
+ elif self ._is_download_active ():
+ delay_ms =random .randint (4000 ,8000 )
+ QTimer .singleShot (delay_ms ,lambda data =link_data :self ._display_and_schedule_next (data ))
+ else :
+ QTimer .singleShot (0 ,lambda data =link_data :self ._display_and_schedule_next (data ))
+
+
+ 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 ()
+
+ 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 ())
+ formatted_link_info =f"{display_text } - {link_url } - {platform }"
+
+ if decryption_key :
+ formatted_link_info +=f" (Decryption Key: {decryption_key })"
+
+ if is_only_links_mode :
+ if post_title !=self ._current_link_post_title :
+ separator_html ="
"+"-"*45 +"
"
+ if self ._current_link_post_title is not None :
+ self .log_signal .emit (HTML_PREFIX +separator_html )
+ title_html =f'{html .escape (post_title )}
'
+ 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 :
+ separator ="-"*45
+ self ._append_to_external_log (formatted_link_info ,separator )
+
+ self ._is_processing_external_link_queue =False
+ self ._try_process_next_external_link ()
+
+
+ def _append_to_external_log (self ,formatted_link_text ,separator ):
+ if not (self .external_log_output and self .external_log_output .isVisible ()):
+ return
+
+ try :
+ self .external_log_output .append (formatted_link_text )
+ self .external_log_output .append ("")
+
+ scrollbar =self .external_log_output .verticalScrollBar ()
+ if scrollbar .value ()>=scrollbar .maximum ()-50 :
+ scrollbar .setValue (scrollbar .maximum ())
+ except Exception as e :
+ self .log_signal .emit (f"GUI External Log Append Error: {e }\nOriginal Message: {formatted_link_text }")
+ print (f"GUI External Log Error (Append): {e }\nOriginal Message: {formatted_link_text }")
+
+
+ def update_file_progress_display (self ,filename ,progress_info ):
+ if not filename and progress_info is None :
+ self .file_progress_label .setText ("")
+ return
+
+ if isinstance (progress_info ,list ):
+ if not progress_info :
+ self .file_progress_label .setText (self ._tr ("downloading_multipart_initializing_text","File: {filename} - Initializing parts...").format (filename =filename ))
+ return
+
+ total_downloaded_overall =sum (cs .get ('downloaded',0 )for cs in progress_info )
+ 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 =self ._tr ("downloading_multipart_text","DL '{filename}...': {downloaded_mb:.1f}/{total_mb:.1f} MB ({parts} parts @ {speed:.2f} MB/s)").format (filename =filename [:20 ],downloaded_mb =dl_mb ,total_mb =total_mb ,parts =active_chunks_count ,speed =speed_MBps )
+ self .file_progress_label .setText (progress_text )
+
+ elif isinstance (progress_info ,tuple )and len (progress_info )==2 :
+ downloaded_bytes ,total_bytes =progress_info
+
+ if not filename and total_bytes ==0 and downloaded_bytes ==0 :
+ self .file_progress_label .setText ("")
+ return
+
+ 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 )
+ if total_bytes >0 :
+ tot_mb =total_bytes /(1024 *1024 )
+ prog_text_base =self ._tr ("downloading_file_known_size_text","Downloading '{filename}' ({downloaded_mb:.1f}MB / {total_mb:.1f}MB)").format (filename =disp_fn ,downloaded_mb =dl_mb ,total_mb =tot_mb )
+ else :
+ prog_text_base =self ._tr ("downloading_file_unknown_size_text","Downloading '{filename}' ({downloaded_mb:.1f}MB)").format (filename =disp_fn ,downloaded_mb =dl_mb )
+
+ self .file_progress_label .setText (prog_text_base )
+ elif filename and progress_info is None :
+ self .file_progress_label .setText ("")
+ elif not filename and not progress_info :
+ self .file_progress_label .setText ("")
+
+
+ def update_external_links_setting (self ,checked ):
+ is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
+ is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked ()
+
+ if is_only_links_mode or is_only_archives_mode :
+ if self .external_log_output :self .external_log_output .hide ()
+ if self .log_splitter :self .log_splitter .setSizes ([self .height (),0 ])
+ return
+
+ self .show_external_links =checked
+ if checked :
+ if self .external_log_output :self .external_log_output .show ()
+ 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 :
+ self .external_log_output .clear ()
+ self .external_log_output .append ("🔗 External Links Found:")
+ self ._try_process_next_external_link ()
+ else :
+ 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 .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 )
+
+
+ def _handle_filter_mode_change (self ,button ,checked ):
+ if not button or not checked :
+ return
+
+
+ is_only_links =(button ==self .radio_only_links )
+ is_only_audio =(hasattr (self ,'radio_only_audio')and self .radio_only_audio is not None and button ==self .radio_only_audio )
+ is_only_archives =(hasattr (self ,'radio_only_archives')and self .radio_only_archives is not None and button ==self .radio_only_archives )
+
+ if self .skip_scope_toggle_button :
+ self .skip_scope_toggle_button .setVisible (not (is_only_links or is_only_archives or is_only_audio ))
+ if hasattr (self ,'multipart_toggle_button')and self .multipart_toggle_button :
+ self .multipart_toggle_button .setVisible (not (is_only_links or is_only_archives or is_only_audio ))
+
+ 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 )
+ self .export_links_button .setEnabled (is_only_links and bool (self .extracted_links_cache ))
+
+ if hasattr (self ,'download_extracted_links_button')and self .download_extracted_links_button :
+ self .download_extracted_links_button .setVisible (is_only_links )
+ self ._update_download_extracted_links_button_state ()
+
+ if self .download_btn :
+ if is_only_links :
+ self .download_btn .setText (self ._tr ("extract_links_button_text","🔗 Extract Links"))
+ else :
+ self .download_btn .setText (self ._tr ("start_download_button_text","⬇️ Start Download"))
+ if not is_only_links and self .link_search_input :self .link_search_input .clear ()
+
+ file_download_mode_active =not is_only_links
+
+
+
+ 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 )
+ if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .setEnabled (file_download_mode_active )
+
+ if self .skip_zip_checkbox :
+ can_skip_zip =file_download_mode_active and not is_only_archives
+ self .skip_zip_checkbox .setEnabled (can_skip_zip )
+ if is_only_archives :
+ self .skip_zip_checkbox .setChecked (False )
+
+ if self .skip_rar_checkbox :
+ can_skip_rar =file_download_mode_active and not is_only_archives
+ self .skip_rar_checkbox .setEnabled (can_skip_rar )
+ if is_only_archives :
+ self .skip_rar_checkbox .setChecked (False )
+
+ other_file_proc_enabled =file_download_mode_active 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 =file_download_mode_active and not is_only_archives
+ self .external_links_checkbox .setEnabled (can_show_external_log_option )
+ 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 ])
+
+
+ do_clear_log_in_filter_change =True
+ if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
+ do_clear_log_in_filter_change =False
+
+ if self .main_log_output and do_clear_log_in_filter_change :
+ self .log_signal .emit ("INTERNAL: _handle_filter_mode_change - About to clear log.")
+ self .main_log_output .clear ()
+ self .log_signal .emit ("INTERNAL: _handle_filter_mode_change - Log cleared by _handle_filter_mode_change.")
+
+ if self .main_log_output :self .main_log_output .setMinimumHeight (0 )
+ self .log_signal .emit ("="*20 +" Mode changed to: Only Links "+"="*20 )
+ 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 )
+ elif is_only_audio :
+ self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")+f" ({self ._tr ('filter_audio_radio','🎧 Only Audio')})")
+ 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 +f" Mode changed to: {self ._tr ('filter_audio_radio','🎧 Only Audio')} "+"="*20 )
+ else :
+ self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
+ self .update_external_links_setting (self .external_links_checkbox .isChecked ()if self .external_links_checkbox else False )
+ self .log_signal .emit (f"="*20 +f" Mode changed to: {button .text ()} "+"="*20 )
+
+
+ if is_only_links :
+ self ._filter_links_log ()
+
+ if hasattr (self ,'log_display_mode_toggle_button'):
+ self .log_display_mode_toggle_button .setVisible (is_only_links )
+ self ._update_log_display_mode_button_text ()
+
+ subfolders_on =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False
+ manga_on =self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False
+
+ character_filter_should_be_active =file_download_mode_active and not is_only_archives
+
+ if self .character_filter_widget :
+ self .character_filter_widget .setVisible (character_filter_should_be_active )
+
+ enable_character_filter_related_widgets =character_filter_should_be_active
+
+ 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 )
+ self .update_custom_folder_visibility ()
+ self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
+
+
+ def _filter_links_log (self ):
+ 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 ""
+
+ 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).")
+ 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.")
+
+ else :
+
+ 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 ="
"+"-"*45 +"
"
+
+ 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'{html .escape (post_title )}
'
+ 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)")
+
+
+ if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ())
+
+
+ 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 (*)")
+
+ if filepath :
+ try :
+ with open (filepath ,'w',encoding ='utf-8')as f :
+ current_title_for_export =None
+ separator ="-"*60 +"\n"
+ for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
+ if post_title !=current_title_for_export :
+ if current_title_for_export is not None :
+ f .write ("\n"+separator +"\n")
+ f .write (f"Post Title: {post_title }\n\n")
+ current_title_for_export =post_title
+ line_to_write =f" {link_text } - {link_url } - {platform }"
+ if decryption_key :
+ line_to_write +=f" (Decryption Key: {decryption_key })"
+ f .write (line_to_write +"\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 }")
+
+
+ def get_filter_mode (self ):
+ if self .radio_only_links and self .radio_only_links .isChecked ():
+ return 'all'
+ elif self .radio_images .isChecked ():
+ return 'image'
+ elif self .radio_videos .isChecked ():
+ return 'video'
+ elif self .radio_only_archives and self .radio_only_archives .isChecked ():
+ return 'archive'
+ elif hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ():
+ return 'audio'
+ elif self .radio_all .isChecked ():
+ return 'all'
+ return 'all'
+
+
+ def get_skip_words_scope (self ):
+ return self .skip_words_scope
+
+
+ def _update_skip_scope_button_text (self ):
+ if self .skip_scope_toggle_button :
+ if self .skip_words_scope ==SKIP_SCOPE_FILES :
+ self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_files_text","Scope: Files"))
+ self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_files_tooltip","Tooltip for skip scope files"))
+ elif self .skip_words_scope ==SKIP_SCOPE_POSTS :
+ self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_posts_text","Scope: Posts"))
+ self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_posts_tooltip","Tooltip for skip scope posts"))
+ elif self .skip_words_scope ==SKIP_SCOPE_BOTH :
+ self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_both_text","Scope: Both"))
+ self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_both_tooltip","Tooltip for skip scope both"))
+ else :
+ self .skip_scope_toggle_button .setText (self ._tr ("skip_scope_unknown_text","Scope: Unknown"))
+ self .skip_scope_toggle_button .setToolTip (self ._tr ("skip_scope_unknown_tooltip","Tooltip for skip scope unknown"))
+
+
+ def _cycle_skip_scope (self ):
+ if self .skip_words_scope ==SKIP_SCOPE_POSTS :
+ self .skip_words_scope =SKIP_SCOPE_FILES
+ elif self .skip_words_scope ==SKIP_SCOPE_FILES :
+ self .skip_words_scope =SKIP_SCOPE_BOTH
+ elif self .skip_words_scope ==SKIP_SCOPE_BOTH :
+ self .skip_words_scope =SKIP_SCOPE_POSTS
+ else :
+ self .skip_words_scope =SKIP_SCOPE_POSTS
+
+ 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 (self ._tr ("char_filter_scope_files_text","Filter: Files"))
+ self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_files_tooltip","Tooltip for char filter files"))
+ elif self .char_filter_scope ==CHAR_SCOPE_TITLE :
+ self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_title_text","Filter: Title"))
+ self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_title_tooltip","Tooltip for char filter title"))
+ elif self .char_filter_scope ==CHAR_SCOPE_BOTH :
+ self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_both_text","Filter: Both"))
+ self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_both_tooltip","Tooltip for char filter both"))
+ elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS :
+ self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_comments_text","Filter: Comments (Beta)"))
+ self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_comments_tooltip","Tooltip for char filter comments"))
+ else :
+ self .char_filter_scope_toggle_button .setText (self ._tr ("char_filter_scope_unknown_text","Filter: Unknown"))
+ self .char_filter_scope_toggle_button .setToolTip (self ._tr ("char_filter_scope_unknown_tooltip","Tooltip for char filter unknown"))
+
+ def _cycle_char_filter_scope (self ):
+ if self .char_filter_scope ==CHAR_SCOPE_TITLE :
+ self .char_filter_scope =CHAR_SCOPE_FILES
+ elif self .char_filter_scope ==CHAR_SCOPE_FILES :
+ self .char_filter_scope =CHAR_SCOPE_BOTH
+ elif self .char_filter_scope ==CHAR_SCOPE_BOTH :
+ self .char_filter_scope =CHAR_SCOPE_COMMENTS
+ elif self .char_filter_scope ==CHAR_SCOPE_COMMENTS :
+ self .char_filter_scope =CHAR_SCOPE_TITLE
+ else :
+ self .char_filter_scope =CHAR_SCOPE_TITLE
+
+ 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 }'")
+
+ def _handle_ui_add_new_character (self ):
+ """Handles adding a new character from the UI input field."""
+ name_from_ui_input =self .new_char_input .text ().strip ()
+ successfully_added_any =False
+
+ if not name_from_ui_input :
+ QMessageBox .warning (self ,"Input Error","Name cannot be empty.")
+ return
+
+ if name_from_ui_input .startswith ("(")and name_from_ui_input .endswith (")~"):
+ content =name_from_ui_input [1 :-2 ].strip ()
+ aliases =[alias .strip ()for alias in content .split (',')if alias .strip ()]
+ if aliases :
+ folder_name =" ".join (aliases )
+ if self .add_new_character (name_to_add =folder_name ,
+ is_group_to_add =True ,
+ aliases_to_add =aliases ,
+ suppress_similarity_prompt =False ):
+ successfully_added_any =True
+ else :
+ QMessageBox .warning (self ,"Input Error","Empty group content for `~` format.")
+
+ elif name_from_ui_input .startswith ("(")and name_from_ui_input .endswith (")"):
+ content =name_from_ui_input [1 :-1 ].strip ()
+ names_to_add_separately =[name .strip ()for name in content .split (',')if name .strip ()]
+ if names_to_add_separately :
+ for name_item in names_to_add_separately :
+ if self .add_new_character (name_to_add =name_item ,
+ is_group_to_add =False ,
+ aliases_to_add =[name_item ],
+ suppress_similarity_prompt =False ):
+ successfully_added_any =True
+ else :
+ QMessageBox .warning (self ,"Input Error","Empty group content for standard group format.")
+ else :
+ if self .add_new_character (name_to_add =name_from_ui_input ,
+ is_group_to_add =False ,
+ aliases_to_add =[name_from_ui_input ],
+ suppress_similarity_prompt =False ):
+ successfully_added_any =True
+
+ if successfully_added_any :
+ self .new_char_input .clear ()
+ self .save_known_names ()
+
+
+ def add_new_character (self ,name_to_add ,is_group_to_add ,aliases_to_add ,suppress_similarity_prompt =False ):
+ global KNOWN_NAMES ,clean_folder_name
+ if not name_to_add :
+ QMessageBox .warning (self ,"Input Error","Name cannot be empty.");return False
+
+ name_to_add_lower =name_to_add .lower ()
+ for kn_entry in KNOWN_NAMES :
+ if kn_entry ["name"].lower ()==name_to_add_lower :
+ QMessageBox .warning (self ,"Duplicate Name",f"The primary folder name '{name_to_add }' already exists.");return False
+ if not is_group_to_add and name_to_add_lower in [a .lower ()for a in kn_entry ["aliases"]]:
+ QMessageBox .warning (self ,"Duplicate Alias",f"The name '{name_to_add }' already exists as an alias for '{kn_entry ['name']}'.");return False
+
+ similar_names_details =[]
+ for kn_entry in KNOWN_NAMES :
+ for term_to_check_similarity_against in kn_entry ["aliases"]:
+ term_lower =term_to_check_similarity_against .lower ()
+ if name_to_add_lower !=term_lower and (name_to_add_lower in term_lower or term_lower in name_to_add_lower ):
+ similar_names_details .append ((name_to_add ,kn_entry ["name"]))
+ break
+ for new_alias in aliases_to_add :
+ if new_alias .lower ()!=term_to_check_similarity_against .lower ()and (new_alias .lower ()in term_to_check_similarity_against .lower ()or term_to_check_similarity_against .lower ()in new_alias .lower ()):
+ similar_names_details .append ((new_alias ,kn_entry ["name"]))
+ break
+
+ if similar_names_details and not suppress_similarity_prompt :
+ if similar_names_details :
+ first_similar_new ,first_similar_existing =similar_names_details [0 ]
+ shorter ,longer =sorted ([first_similar_new ,first_similar_existing ],key =len )
+
+ msg_box =QMessageBox (self )
+ msg_box .setIcon (QMessageBox .Warning )
+ msg_box .setWindowTitle ("Potential Name Conflict")
+ msg_box .setText (
+ f"The name '{first_similar_new }' is very similar to an existing name: '{first_similar_existing }'.\n\n"
+ f"This could lead to unexpected folder grouping (e.g., under '{clean_folder_name (shorter )}' instead of a more specific '{clean_folder_name (longer )}' or vice-versa).\n\n"
+ "Do you want to change the name you are adding, or proceed anyway?"
+ )
+ change_button =msg_box .addButton ("Change Name",QMessageBox .RejectRole )
+ proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
+ msg_box .setDefaultButton (proceed_button )
+ msg_box .setEscapeButton (change_button )
+ msg_box .exec_ ()
+
+ if msg_box .clickedButton ()==change_button :
+ self .log_signal .emit (f"ℹ️ User chose to change '{first_similar_new }' due to similarity with an alias of '{first_similar_existing }'.")
+ return False
+ self .log_signal .emit (f"⚠️ User proceeded with adding '{first_similar_new }' despite similarity with an alias of '{first_similar_existing }'.")
+ new_entry ={
+ "name":name_to_add ,
+ "is_group":is_group_to_add ,
+ "aliases":sorted (list (set (aliases_to_add )),key =str .lower )
+ }
+ if is_group_to_add :
+ for new_alias in new_entry ["aliases"]:
+ if any (new_alias .lower ()==kn_entry ["name"].lower ()for kn_entry in KNOWN_NAMES if kn_entry ["name"].lower ()!=name_to_add_lower ):
+ QMessageBox .warning (self ,"Alias Conflict",f"Alias '{new_alias }' (for group '{name_to_add }') conflicts with an existing primary name.");return False
+ KNOWN_NAMES .append (new_entry )
+ KNOWN_NAMES .sort (key =lambda x :x ["name"].lower ())
+
+ self .character_list .clear ()
+ self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
+ self .filter_character_list (self .character_search_input .text ())
+
+ log_msg_suffix =f" (as group with aliases: {', '.join (new_entry ['aliases'])})"if is_group_to_add and len (new_entry ['aliases'])>1 else ""
+ self .log_signal .emit (f"✅ Added '{name_to_add }' to known names list{log_msg_suffix }.")
+ self .new_char_input .clear ()
+ return True
+
+
+ def delete_selected_character (self ):
+ global KNOWN_NAMES
+ selected_items =self .character_list .selectedItems ()
+ if not selected_items :
+ QMessageBox .warning (self ,"Selection Error","Please select one or more names to delete.");return
+
+ primary_names_to_remove ={item .text ()for item in selected_items }
+ confirm =QMessageBox .question (self ,"Confirm Deletion",
+ f"Are you sure you want to delete {len (primary_names_to_remove )} selected entry/entries (and their aliases)?",
+ QMessageBox .Yes |QMessageBox .No ,QMessageBox .No )
+ if confirm ==QMessageBox .Yes :
+ original_count =len (KNOWN_NAMES )
+ KNOWN_NAMES [:]=[entry for entry in KNOWN_NAMES if entry ["name"]not in primary_names_to_remove ]
+ removed_count =original_count -len (KNOWN_NAMES )
+
+ if removed_count >0 :
+ self .log_signal .emit (f"🗑️ Removed {removed_count } name(s).")
+ self .character_list .clear ()
+ self .character_list .addItems ([entry ["name"]for entry in KNOWN_NAMES ])
+ self .filter_character_list (self .character_search_input .text ())
+ self .save_known_names ()
+ else :
+ self .log_signal .emit ("ℹ️ No names were removed (they might not have been in the list).")
+
+
+ def update_custom_folder_visibility (self ,url_text =None ):
+ if url_text is None :
+ url_text =self .link_input .text ()
+
+ _ ,_ ,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
+
+ 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 ())or
+ (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
+
+ if self .custom_folder_widget :
+ self .custom_folder_widget .setVisible (should_show_custom_folder )
+
+ if not (self .custom_folder_widget and self .custom_folder_widget .isVisible ()):
+ if self .custom_folder_input :self .custom_folder_input .clear ()
+
+
+ def update_ui_for_subfolders (self ,separate_folders_by_name_title_checked :bool ):
+ 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 ()
+ is_only_audio =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
+
+ can_enable_subfolder_per_post_checkbox =not is_only_links
+
+ if self .use_subfolder_per_post_checkbox :
+ self .use_subfolder_per_post_checkbox .setEnabled (can_enable_subfolder_per_post_checkbox )
+
+ if not can_enable_subfolder_per_post_checkbox :
+ self .use_subfolder_per_post_checkbox .setChecked (False )
+
+ self .update_custom_folder_visibility ()
+
+
+ def _update_cookie_input_visibility (self ,checked ):
+ cookie_text_input_exists =hasattr (self ,'cookie_text_input')
+ cookie_browse_button_exists =hasattr (self ,'cookie_browse_button')
+
+ if cookie_text_input_exists or cookie_browse_button_exists :
+ is_only_links =self .radio_only_links and self .radio_only_links .isChecked ()
+ if cookie_text_input_exists :self .cookie_text_input .setVisible (checked )
+ if cookie_browse_button_exists :self .cookie_browse_button .setVisible (checked )
+
+ can_enable_cookie_text =checked and not is_only_links
+ enable_state_for_fields =can_enable_cookie_text and (self .download_btn .isEnabled ()or self .is_paused )
+
+ if cookie_text_input_exists :
+ self .cookie_text_input .setEnabled (enable_state_for_fields )
+ if self .selected_cookie_filepath and checked :
+ self .cookie_text_input .setText (self .selected_cookie_filepath )
+ self .cookie_text_input .setReadOnly (True )
+ self .cookie_text_input .setPlaceholderText ("")
+ elif checked :
+ self .cookie_text_input .setReadOnly (False )
+ self .cookie_text_input .setPlaceholderText ("Cookie string (if no cookies.txt)")
+
+ if cookie_browse_button_exists :self .cookie_browse_button .setEnabled (enable_state_for_fields )
+
+ if not checked :
+ self .selected_cookie_filepath =None
+
+
+ def update_page_range_enabled_state (self ):
+ url_text =self .link_input .text ().strip ()if self .link_input else ""
+ _ ,_ ,post_id =extract_post_info (url_text )
+
+ is_creator_feed =not post_id if url_text else False
+ enable_page_range =is_creator_feed
+
+ for widget in [self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ]:
+ if widget :widget .setEnabled (enable_page_range )
+
+ if not enable_page_range :
+ 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 ):
+ if self .manga_rename_toggle_button :
+ if self .manga_filename_style ==STYLE_POST_TITLE :
+ 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"))
+
+ 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"))
+
+ elif self .manga_filename_style ==STYLE_DATE_BASED :
+ self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_based_text","Name: Date Based"))
+
+
+ elif self .manga_filename_style ==STYLE_DATE_POST_TITLE :
+ self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_post_title_text","Name: Date + Title"))
+
+ 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 ):
+ current_style =self .manga_filename_style
+ new_style =""
+ if current_style ==STYLE_POST_TITLE :
+ new_style =STYLE_ORIGINAL_NAME
+ elif current_style ==STYLE_ORIGINAL_NAME :
+ new_style =STYLE_DATE_POST_TITLE
+ elif current_style ==STYLE_DATE_POST_TITLE :
+ new_style =STYLE_POST_TITLE_GLOBAL_NUMBERING
+ elif current_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
+ new_style =STYLE_DATE_BASED
+ elif current_style ==STYLE_DATE_BASED :
+ new_style =STYLE_POST_TITLE
+ else :
+ self .log_signal .emit (f"⚠️ Unknown current manga filename style: {current_style }. Resetting to default ('{STYLE_POST_TITLE }').")
+ new_style =STYLE_POST_TITLE
+
+ 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 .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
+ self .log_signal .emit (f"ℹ️ Manga filename style changed to: '{self .manga_filename_style }'")
+
+ def _handle_favorite_mode_toggle (self ,checked ):
+ if not self .url_or_placeholder_stack or not self .bottom_action_buttons_stack :
+ return
+
+ self .url_or_placeholder_stack .setCurrentIndex (1 if checked else 0 )
+ self .bottom_action_buttons_stack .setCurrentIndex (1 if checked else 0 )
+
+ if checked :
+ if self .link_input :
+ self .link_input .clear ()
+ self .link_input .setEnabled (False )
+ for widget in [self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ]:
+ if widget :widget .setEnabled (False )
+ if self .start_page_input :self .start_page_input .clear ()
+ if self .end_page_input :self .end_page_input .clear ()
+
+ self .update_custom_folder_visibility ()
+ self .update_page_range_enabled_state ()
+ if self .manga_mode_checkbox :
+ self .manga_mode_checkbox .setChecked (False )
+ self .manga_mode_checkbox .setEnabled (False )
+ if hasattr (self ,'use_cookie_checkbox'):
+ self .use_cookie_checkbox .setChecked (True )
+ self .use_cookie_checkbox .setEnabled (False )
+ if hasattr (self ,'use_cookie_checkbox'):
+ self ._update_cookie_input_visibility (True )
+ self .update_ui_for_manga_mode (False )
+
+ if hasattr (self ,'favorite_mode_artists_button'):
+ self .favorite_mode_artists_button .setEnabled (True )
+ if hasattr (self ,'favorite_mode_posts_button'):
+ self .favorite_mode_posts_button .setEnabled (True )
+
+ else :
+ if self .link_input :self .link_input .setEnabled (True )
+ self .update_page_range_enabled_state ()
+ self .update_custom_folder_visibility ()
+ self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
+
+ if hasattr (self ,'use_cookie_checkbox'):
+ self .use_cookie_checkbox .setEnabled (True )
+ if hasattr (self ,'use_cookie_checkbox'):
+ self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ())
+
+ if hasattr (self ,'favorite_mode_artists_button'):
+ self .favorite_mode_artists_button .setEnabled (False )
+ if hasattr (self ,'favorite_mode_posts_button'):
+ self .favorite_mode_posts_button .setEnabled (False )
+
+ def update_ui_for_manga_mode (self ,checked ):
+ is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
+ is_only_archives_mode =self .radio_only_archives and self .radio_only_archives .isChecked ()
+ is_only_audio_mode =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
+
+ url_text =self .link_input .text ().strip ()if self .link_input else ""
+ _ ,_ ,post_id =extract_post_info (url_text )
+
+ is_creator_feed =not post_id if url_text else False
+ is_favorite_mode_on =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
+
+ if self .manga_mode_checkbox :
+ self .manga_mode_checkbox .setEnabled (is_creator_feed and not is_favorite_mode_on )
+ if not is_creator_feed and self .manga_mode_checkbox .isChecked ():
+ self .manga_mode_checkbox .setChecked (False )
+ checked =self .manga_mode_checkbox .isChecked ()
+
+ manga_mode_effectively_on =is_creator_feed and checked
+
+ if self .manga_rename_toggle_button :
+ self .manga_rename_toggle_button .setVisible (manga_mode_effectively_on and not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ))
+
+ self .update_page_range_enabled_state ()
+
+ current_filename_style =self .manga_filename_style
+
+ enable_char_filter_widgets =not is_only_links_mode and not is_only_archives_mode
+
+ 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 )
+ if self .character_filter_widget :
+ self .character_filter_widget .setVisible (enable_char_filter_widgets )
+
+ show_date_prefix_input =(
+ manga_mode_effectively_on and
+ (current_filename_style ==STYLE_DATE_BASED or
+ current_filename_style ==STYLE_ORIGINAL_NAME )and
+ not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )
+ )
+ if hasattr (self ,'manga_date_prefix_input'):
+ self .manga_date_prefix_input .setVisible (show_date_prefix_input )
+ if show_date_prefix_input :
+ self .manga_date_prefix_input .setMaximumWidth (120 )
+ self .manga_date_prefix_input .setMinimumWidth (60 )
+ else :
+ self .manga_date_prefix_input .clear ()
+ self .manga_date_prefix_input .setMaximumWidth (16777215 )
+ self .manga_date_prefix_input .setMinimumWidth (0 )
+
+ if hasattr (self ,'multipart_toggle_button'):
+
+ hide_multipart_button_due_mode =is_only_links_mode or is_only_archives_mode or is_only_audio_mode
+ hide_multipart_button_due_manga_mode =manga_mode_effectively_on
+ self .multipart_toggle_button .setVisible (not (hide_multipart_button_due_mode or hide_multipart_button_due_manga_mode ))
+
+ self ._update_multithreading_for_date_mode ()
+
+
+ def filter_character_list (self ,search_text ):
+ search_text_lower =search_text .lower ()
+ for i in range (self .character_list .count ()):
+ item =self .character_list .item (i )
+ item .setHidden (search_text_lower not in item .text ().lower ())
+
+
+ def update_multithreading_label (self ,text ):
+ if self .use_multithreading_checkbox .isChecked ():
+ base_text =self ._tr ("use_multithreading_checkbox_base_label","Use Multithreading")
+ try :
+ num_threads_val =int (text )
+ if num_threads_val >0 :self .use_multithreading_checkbox .setText (f"{base_text } ({num_threads_val } Threads)")
+ else :self .use_multithreading_checkbox .setText (f"{base_text } (Invalid: >0)")
+ except ValueError :
+ self .use_multithreading_checkbox .setText (f"{base_text } (Invalid Input)")
+ else :
+ self .use_multithreading_checkbox .setText (f"{self ._tr ('use_multithreading_checkbox_base_label','Use Multithreading')} (1 Thread)")
+
+
+ def _handle_multithreading_toggle (self ,checked ):
+ if not checked :
+ self .thread_count_input .setEnabled (False )
+ self .thread_count_label .setEnabled (False )
+ self .use_multithreading_checkbox .setText ("Use Multithreading (1 Thread)")
+ else :
+ self .thread_count_input .setEnabled (True )
+ self .thread_count_label .setEnabled (True )
+ self .update_multithreading_label (self .thread_count_input .text ())
+
+ def _update_multithreading_for_date_mode (self ):
+ """
+ Checks if Manga Mode is ON and 'Date Based' style is selected.
+ If so, disables multithreading. Otherwise, enables it.
+ """
+ if not hasattr (self ,'manga_mode_checkbox')or not hasattr (self ,'use_multithreading_checkbox'):
+ return
+
+ manga_on =self .manga_mode_checkbox .isChecked ()
+ is_sequential_style_requiring_single_thread =(
+ self .manga_filename_style ==STYLE_DATE_BASED or
+ self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING
+ )
+ if manga_on and is_sequential_style_requiring_single_thread :
+ if self .use_multithreading_checkbox .isChecked ()or self .use_multithreading_checkbox .isEnabled ():
+ if self .use_multithreading_checkbox .isChecked ():
+ self .log_signal .emit ("ℹ️ Manga Date Mode: Multithreading for post processing has been disabled to ensure correct sequential file numbering.")
+ self .use_multithreading_checkbox .setChecked (False )
+ self .use_multithreading_checkbox .setEnabled (False )
+ self ._handle_multithreading_toggle (False )
+ else :
+ if not self .use_multithreading_checkbox .isEnabled ():
+ self .use_multithreading_checkbox .setEnabled (True )
+ self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ())
+
+ def update_progress_display (self ,total_posts ,processed_posts ):
+ if total_posts >0 :
+ progress_percent =(processed_posts /total_posts )*100
+ self .progress_label .setText (self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =processed_posts ,total_posts =total_posts ,progress_percent =progress_percent ))
+ elif processed_posts >0 :
+ self .progress_label .setText (self ._tr ("progress_processing_post_text","Progress: Processing post {processed_posts}...").format (processed_posts =processed_posts ))
+ else :
+ self .progress_label .setText (self ._tr ("progress_starting_text","Progress: Starting..."))
+
+ if total_posts >0 or processed_posts >0 :
+ self .file_progress_label .setText ("")
+
+
+ def start_download (self ,direct_api_url =None ,override_output_dir =None, is_restore=False ):
+ global KNOWN_NAMES ,BackendDownloadThread ,PostProcessorWorker ,extract_post_info ,clean_folder_name ,MAX_FILE_THREADS_PER_POST_OR_WORKER
+
+ if self ._is_download_active ():
+ QMessageBox.warning(self, "Busy", "A download is already in progress.")
+ return False
+
+
+
+ if not direct_api_url and self .favorite_download_queue and not self .is_processing_favorites_queue :
+ self .log_signal .emit (f"ℹ️ Detected {len (self .favorite_download_queue )} item(s) in the queue. Starting processing...")
+ self .cancellation_message_logged_this_session =False
+ self ._process_next_favorite_download ()
+ return True
+
+ if not is_restore and self.interrupted_session_data:
+ self.log_signal.emit("ℹ️ New download started. Discarding previous interrupted session.")
+ self._clear_session_file()
+ self.interrupted_session_data = None
+ self.is_restore_pending = False
+ api_url =direct_api_url if direct_api_url else self .link_input .text ().strip ()
+ self .download_history_candidates .clear ()
+ self._update_button_states_and_connections() # Ensure buttons are updated to active state
+
+
+ if self .favorite_mode_checkbox and self .favorite_mode_checkbox .isChecked ()and not direct_api_url and not api_url :
+ QMessageBox .information (self ,"Favorite Mode Active",
+ "Favorite Mode is active. Please use the 'Favorite Artists' or 'Favorite Posts' buttons to start downloads in this mode, or uncheck 'Favorite Mode' to use the URL input.")
+ self .set_ui_enabled (True )
+ return False
+
+ main_ui_download_dir =self .dir_input .text ().strip ()
+
+ if not api_url and not self .favorite_download_queue :
+ QMessageBox .critical (self ,"Input Error","URL is required.")
+ return False
+ elif not api_url and self .favorite_download_queue :
+ self .log_signal .emit ("ℹ️ URL input is empty, but queue has items. Processing queue...")
+ self .cancellation_message_logged_this_session =False
+ self ._process_next_favorite_download ()
+ return True
+
+ self .cancellation_message_logged_this_session =False
+ use_subfolders =self .use_subfolders_checkbox .isChecked ()
+ use_post_subfolders =self .use_subfolder_per_post_checkbox .isChecked ()
+ compress_images =self .compress_images_checkbox .isChecked ()
+ download_thumbnails =self .download_thumbnails_checkbox .isChecked ()
+
+ use_multithreading_enabled_by_checkbox =self .use_multithreading_checkbox .isChecked ()
+ try :
+ num_threads_from_gui =int (self .thread_count_input .text ().strip ())
+ if num_threads_from_gui <1 :num_threads_from_gui =1
+ except ValueError :
+ QMessageBox .critical (self ,"Thread Count Error","Invalid number of threads. Please enter a positive number.")
+ return False
+
+ if use_multithreading_enabled_by_checkbox :
+ if num_threads_from_gui >MAX_THREADS :
+ hard_warning_msg =(
+ f"You've entered a thread count ({num_threads_from_gui }) exceeding the maximum of {MAX_THREADS }.\n\n"
+ "Using an extremely high number of threads can lead to:\n"
+ " - Diminishing returns (no significant speed increase).\n"
+ " - Increased system instability or application crashes.\n"
+ " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n"
+ f"The thread count has been automatically capped to {MAX_THREADS } for stability."
+ )
+ QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg )
+ num_threads_from_gui =MAX_THREADS
+ self .thread_count_input .setText (str (MAX_THREADS ))
+ self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.")
+ if SOFT_WARNING_THREAD_THRESHOLD MAX_THREADS :
+ hard_warning_msg =(
+ f"You've entered a thread count ({num_threads_from_gui }) exceeding the maximum of {MAX_THREADS }.\n\n"
+ "Using an extremely high number of threads can lead to:\n"
+ " - Diminishing returns (no significant speed increase).\n"
+ " - Increased system instability or application crashes.\n"
+ " - Higher chance of being rate-limited or temporarily IP-banned by the server.\n\n"
+ f"The thread count has been automatically capped to {MAX_THREADS } for stability."
+ )
+ QMessageBox .warning (self ,"High Thread Count Warning",hard_warning_msg )
+ num_threads_from_gui =MAX_THREADS
+ self .thread_count_input .setText (str (MAX_THREADS ))
+ self .log_signal .emit (f"⚠️ User attempted {num_threads_from_gui } threads, capped to {MAX_THREADS }.")
+ if SOFT_WARNING_THREAD_THRESHOLD end_page :raise ValueError ("Start page cannot be greater than end page.")
+
+ if manga_mode and start_page and end_page :
+ msg_box =QMessageBox (self )
+ msg_box .setIcon (QMessageBox .Warning )
+ msg_box .setWindowTitle ("Manga Mode & Page Range Warning")
+ msg_box .setText (
+ "You have enabled Manga/Comic Mode and also specified a Page Range.\n\n"
+ "Manga Mode processes posts from oldest to newest across all available pages by default.\n"
+ "If you use a page range, you might miss parts of the manga/comic if it starts before your 'Start Page' or continues after your 'End Page'.\n\n"
+ "However, if you are certain the content you want is entirely within this page range (e.g., a short series, or you know the specific pages for a volume), then proceeding is okay.\n\n"
+ "Do you want to proceed with this page range in Manga Mode?"
+ )
+ proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
+ cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole )
+ msg_box .setDefaultButton (proceed_button )
+ msg_box .setEscapeButton (cancel_button )
+ msg_box .exec_ ()
+
+ if msg_box .clickedButton ()==cancel_button :
+ self .log_signal .emit ("❌ Download cancelled by user due to Manga Mode & Page Range warning.")
+ return False
+ except ValueError as e :
+ QMessageBox .critical (self ,"Page Range Error",f"Invalid page range: {e }")
+ return False
+ self .external_link_queue .clear ();self .extracted_links_cache =[];self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
+
+ raw_character_filters_text =self .character_input .text ().strip ()
+ parsed_character_filter_objects =self ._parse_character_filters (raw_character_filters_text )
+
+ actual_filters_to_use_for_run =[]
+
+ needs_folder_naming_validation =(use_subfolders or manga_mode )and not extract_links_only
+
+ if parsed_character_filter_objects :
+ actual_filters_to_use_for_run =parsed_character_filter_objects
+
+ if not extract_links_only :
+ self .log_signal .emit (f"ℹ️ Using character filters for matching: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
+
+ filter_objects_to_potentially_add_to_known_list =[]
+ 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 )
+ if needs_folder_naming_validation and not cleaned_name_test :
+ QMessageBox .warning (self ,"Invalid Filter Name for Folder",f"Filter name '{item_primary_name }' is invalid for a folder and will be skipped for Known.txt interaction.")
+ self .log_signal .emit (f"⚠️ Skipping invalid filter for Known.txt interaction: '{item_primary_name }'")
+ continue
+
+ an_alias_is_already_known =False
+ if any (kn_entry ["name"].lower ()==item_primary_name .lower ()for kn_entry in KNOWN_NAMES ):
+ an_alias_is_already_known =True
+ elif filter_item_obj ["is_group"]and needs_folder_naming_validation :
+ for alias_in_filter_obj in filter_item_obj ["aliases"]:
+ if any (kn_entry ["name"].lower ()==alias_in_filter_obj .lower ()or alias_in_filter_obj .lower ()in [a .lower ()for a in kn_entry ["aliases"]]for kn_entry in KNOWN_NAMES ):
+ an_alias_is_already_known =True ;break
+
+ if an_alias_is_already_known and filter_item_obj ["is_group"]:
+ self .log_signal .emit (f"ℹ️ An alias from group '{item_primary_name }' is already known. Group will not be prompted for Known.txt addition.")
+
+ should_prompt_to_add_to_known_list =(
+ needs_folder_naming_validation and not manga_mode and
+ not any (kn_entry ["name"].lower ()==item_primary_name .lower ()for kn_entry in KNOWN_NAMES )and
+ not an_alias_is_already_known
+ )
+ if should_prompt_to_add_to_known_list :
+ if not any (obj_to_add ["name"].lower ()==item_primary_name .lower ()for obj_to_add in filter_objects_to_potentially_add_to_known_list ):
+ filter_objects_to_potentially_add_to_known_list .append (filter_item_obj )
+ elif manga_mode and needs_folder_naming_validation and item_primary_name .lower ()not in {kn_entry ["name"].lower ()for kn_entry in KNOWN_NAMES }and not an_alias_is_already_known :
+ self .log_signal .emit (f"ℹ️ Manga Mode: Using filter '{item_primary_name }' for this session without adding to Known Names.")
+
+ if filter_objects_to_potentially_add_to_known_list :
+ confirm_dialog =ConfirmAddAllDialog (filter_objects_to_potentially_add_to_known_list ,self ,self )
+ dialog_result =confirm_dialog .exec_ ()
+
+ if dialog_result ==CONFIRM_ADD_ALL_CANCEL_DOWNLOAD :
+ self .log_signal .emit ("❌ Download cancelled by user at new name confirmation stage.")
+ return False
+ elif isinstance (dialog_result ,list ):
+ if dialog_result :
+ self .log_signal .emit (f"ℹ️ User chose to add {len (dialog_result )} new entry/entries to Known.txt.")
+ for filter_obj_to_add in dialog_result :
+ if filter_obj_to_add .get ("components_are_distinct_for_known_txt"):
+ self .log_signal .emit (f" Processing group '{filter_obj_to_add ['name']}' to add its components individually to Known.txt.")
+ for alias_component in filter_obj_to_add ["aliases"]:
+ self .add_new_character (
+ name_to_add =alias_component ,
+ is_group_to_add =False ,
+ aliases_to_add =[alias_component ],
+ suppress_similarity_prompt =True
+ )
+ else :
+ self .add_new_character (
+ name_to_add =filter_obj_to_add ["name"],
+ is_group_to_add =filter_obj_to_add ["is_group"],
+ aliases_to_add =filter_obj_to_add ["aliases"],
+ suppress_similarity_prompt =True
+ )
+ else :
+ self .log_signal .emit ("ℹ️ User confirmed adding, but no names were selected in the dialog. No new names added to Known.txt.")
+ elif dialog_result ==CONFIRM_ADD_ALL_SKIP_ADDING :
+ self .log_signal .emit ("ℹ️ User chose not to add new names to Known.txt for this session.")
+ else :
+ self .log_signal .emit (f"ℹ️ Using character filters for link extraction: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
+
+
+ if manga_mode and not actual_filters_to_use_for_run and not extract_links_only :
+ msg_box =QMessageBox (self )
+ msg_box .setIcon (QMessageBox .Warning )
+ msg_box .setWindowTitle ("Manga Mode Filter Warning")
+ msg_box .setText (
+ "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)?"
+ )
+ proceed_button =msg_box .addButton ("Proceed Anyway",QMessageBox .AcceptRole )
+ cancel_button =msg_box .addButton ("Cancel Download",QMessageBox .RejectRole )
+ msg_box .exec_ ()
+ if msg_box .clickedButton ()==cancel_button :
+ self .log_signal .emit ("❌ Download cancelled due to Manga Mode filter warning.")
+ return False
+ else :
+ self .log_signal .emit ("⚠️ Proceeding with Manga Mode without a specific title filter.")
+ self .dynamic_character_filter_holder .set_filters (actual_filters_to_use_for_run )
+
+
+ creator_folder_ignore_words_for_run =None
+ character_filters_are_empty =not actual_filters_to_use_for_run
+ if is_full_creator_download and character_filters_are_empty :
+ creator_folder_ignore_words_for_run =CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS
+ log_messages .append (f" Creator Download (No Char Filter): Applying default folder name ignore list ({len (creator_folder_ignore_words_for_run )} words).")
+
+ custom_folder_name_cleaned =None
+ if use_subfolders and post_id_from_url and self .custom_folder_widget and self .custom_folder_widget .isVisible ()and not extract_links_only :
+ 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
+ else :self .log_signal .emit (f"⚠️ Invalid custom folder name ignored: '{raw_custom_name }' (resulted in empty string after cleaning).")
+
+
+ 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...")
+
+ if self .external_log_output :self .external_log_output .clear ()
+ if self .show_external_links and not extract_links_only and backend_filter_mode !='archive':
+ self .external_log_output .append ("🔗 External Links Found:")
+
+ self .file_progress_label .setText ("");self .cancellation_event .clear ();self .active_futures =[]
+ self .total_posts_to_process =0 ;self .processed_posts_count =0 ;self .download_counter =0 ;self .skip_counter =0
+ self .progress_label .setText (self ._tr ("progress_initializing_text","Progress: Initializing..."))
+
+ self .retryable_failed_files_info .clear ()
+ self .permanently_failed_files_for_dialog .clear ()
+
+ manga_date_file_counter_ref_for_thread =None
+ if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED and not extract_links_only :
+ manga_date_file_counter_ref_for_thread =None
+ self .log_signal .emit (f"ℹ️ Manga Date Mode: File counter will be initialized by the download thread.")
+
+ manga_global_file_counter_ref_for_thread =None
+ if manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING and not extract_links_only :
+ manga_global_file_counter_ref_for_thread =None
+ self .log_signal .emit (f"ℹ️ Manga Title+GlobalNum Mode: File counter will be initialized by the download thread (starts at 1).")
+
+ effective_num_post_workers =1
+
+ effective_num_file_threads_per_worker =1
+
+ if post_id_from_url :
+ if use_multithreading_enabled_by_checkbox :
+ effective_num_file_threads_per_worker =max (1 ,min (num_threads_from_gui ,MAX_FILE_THREADS_PER_POST_OR_WORKER ))
+ else :
+ if manga_mode and self .manga_filename_style ==STYLE_DATE_BASED :
+ effective_num_post_workers =1
+ elif manga_mode and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
+ effective_num_post_workers =1
+ effective_num_file_threads_per_worker =1
+ elif use_multithreading_enabled_by_checkbox :
+ effective_num_post_workers =max (1 ,min (num_threads_from_gui ,MAX_THREADS ))
+ effective_num_file_threads_per_worker =1
+
+ if not extract_links_only :log_messages .append (f" Save Location: {effective_output_dir_for_run }")
+
+ if post_id_from_url :
+ log_messages .append (f" Mode: Single Post")
+ log_messages .append (f" ↳ File Downloads: Up to {effective_num_file_threads_per_worker } concurrent file(s)")
+ 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 ()
+
+ if manga_mode :
+ log_messages .append (f" Page Range: {pr_log if pr_log else 'All'} (Manga Mode - Oldest Posts Processed First within range)")
+ else :
+ log_messages .append (f" Page Range: {pr_log if pr_log else 'All'}")
+
+
+ if not extract_links_only :
+ log_messages .append (f" Subfolders: {'Enabled'if use_subfolders else 'Disabled'}")
+ if use_subfolders :
+ if custom_folder_name_cleaned :log_messages .append (f" Custom Folder (Post): '{custom_folder_name_cleaned }'")
+ if actual_filters_to_use_for_run :
+ log_messages .append (f" Character Filters: {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
+ 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)")
+
+
+ log_messages .extend ([
+ f" File Type Filter: {user_selected_filter_text } (Backend processing as: {backend_filter_mode })",
+ 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 '')}",
+ f" Skip Words (posts/files): {', '.join (skip_words_list )if skip_words_list else 'None'}",
+ f" Skip Words Scope: {current_skip_words_scope .capitalize ()}",
+ f" Remove Words from Filename: {', '.join (remove_from_filename_words_list )if remove_from_filename_words_list else 'None'}",
+ f" Compress Images: {'Enabled'if compress_images else 'Disabled'}",
+ f" Thumbnails Only: {'Enabled'if download_thumbnails else 'Disabled'}"
+ ])
+ log_messages .append (f" Scan Post Content for Images: {'Enabled'if scan_content_for_images else 'Disabled'}")
+ else :
+ 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'}")
+
+ if manga_mode :
+ 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'}")
+ if actual_filters_to_use_for_run :
+ log_messages .append (f" ↳ Manga Character Filter (for naming/folder): {', '.join (item ['name']for item in actual_filters_to_use_for_run )}")
+ log_messages .append (f" ↳ Manga Duplicates: Will be renamed with numeric suffix if names clash (e.g., _1, _2).")
+
+ log_messages .append (f" Use Cookie ('cookies.txt'): {'Enabled'if use_cookie_from_checkbox else 'Disabled'}")
+ if use_cookie_from_checkbox and cookie_text_from_input :
+ log_messages .append (f" ↳ Cookie Text Provided: Yes (length: {len (cookie_text_from_input )})")
+ elif use_cookie_from_checkbox and selected_cookie_file_path_for_backend :
+ log_messages .append (f" ↳ Cookie File Selected: {os .path .basename (selected_cookie_file_path_for_backend )}")
+ should_use_multithreading_for_posts =use_multithreading_enabled_by_checkbox and not post_id_from_url
+ if manga_mode and (self .manga_filename_style ==STYLE_DATE_BASED or self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING )and not post_id_from_url :
+ enforced_by_style ="Date Mode"if self .manga_filename_style ==STYLE_DATE_BASED else "Title+GlobalNum Mode"
+ should_use_multithreading_for_posts =False
+ log_messages .append (f" Threading: Single-threaded (posts) - Enforced by Manga {enforced_by_style } (Actual workers: {effective_num_post_workers if effective_num_post_workers >1 else 1 })")
+ else :
+ log_messages .append (f" Threading: {'Multi-threaded (posts)'if should_use_multithreading_for_posts else 'Single-threaded (posts)'}")
+ if should_use_multithreading_for_posts :
+ log_messages .append (f" Number of Post Worker Threads: {effective_num_post_workers }")
+ log_messages .append ("="*40 )
+ for msg in log_messages :self .log_signal .emit (msg )
+
+ self .set_ui_enabled (False )
+
+
+ from src.config.constants import FOLDER_NAME_STOP_WORDS
+
+
+ args_template ={
+ 'api_url_input':api_url ,
+ 'download_root':effective_output_dir_for_run ,
+ 'output_dir':effective_output_dir_for_run ,
+ 'known_names':list (KNOWN_NAMES ),
+ 'known_names_copy':list (KNOWN_NAMES ),
+ 'filter_character_list':actual_filters_to_use_for_run ,
+ 'filter_mode':backend_filter_mode ,
+ 'skip_zip':effective_skip_zip ,
+ 'skip_rar':effective_skip_rar ,
+ 'use_subfolders':use_subfolders ,
+ 'use_post_subfolders':use_post_subfolders ,
+ 'compress_images':compress_images ,
+ 'download_thumbnails':download_thumbnails ,
+ 'service':service ,
+ 'user_id':user_id ,
+ 'downloaded_files':self .downloaded_files ,
+ 'downloaded_files_lock':self .downloaded_files_lock ,
+ 'downloaded_file_hashes':self .downloaded_file_hashes ,
+ 'downloaded_file_hashes_lock':self .downloaded_file_hashes_lock ,
+ 'skip_words_list':skip_words_list ,
+ 'skip_words_scope':current_skip_words_scope ,
+ 'remove_from_filename_words_list':remove_from_filename_words_list ,
+ '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':FOLDER_NAME_STOP_WORDS ,
+ 'cancellation_event':self .cancellation_event ,
+ 'manga_date_prefix':manga_date_prefix_text ,
+ 'dynamic_character_filter_holder':self .dynamic_character_filter_holder ,
+ 'pause_event':self .pause_event ,
+ 'scan_content_for_images':scan_content_for_images ,
+ 'manga_filename_style':self .manga_filename_style ,
+ 'num_file_threads_for_worker':effective_num_file_threads_per_worker ,
+ 'manga_date_file_counter_ref':manga_date_file_counter_ref_for_thread ,
+ 'allow_multipart_download':allow_multipart ,
+ 'cookie_text':cookie_text_from_input ,
+ 'selected_cookie_file':selected_cookie_file_path_for_backend ,
+ 'manga_global_file_counter_ref':manga_global_file_counter_ref_for_thread ,
+ 'app_base_dir':app_base_dir_for_cookies ,
+ 'use_cookie':use_cookie_for_this_run ,
+ 'session_file_path': self.session_file_path,
+ 'session_lock': self.session_lock,
+ 'creator_download_folder_ignore_words':creator_folder_ignore_words_for_run ,
+ }
+
+ args_template ['override_output_dir']=override_output_dir
+ try :
+ if should_use_multithreading_for_posts :
+ self .log_signal .emit (f" Initializing multi-threaded {current_mode_log_text .lower ()} with {effective_num_post_workers } post workers...")
+ args_template ['emitter']=self .worker_to_gui_queue
+ self .start_multi_threaded_download (num_post_workers =effective_num_post_workers ,**args_template )
+ else :
+ self .log_signal .emit (f" Initializing single-threaded {'link extraction'if extract_links_only else 'download'}...")
+ dt_expected_keys =[
+ 'api_url_input','output_dir','known_names_copy','cancellation_event',
+ 'filter_character_list','filter_mode','skip_zip','skip_rar',
+ 'use_subfolders','use_post_subfolders','custom_folder_name',
+ 'compress_images','download_thumbnails','service','user_id',
+ 'downloaded_files','downloaded_file_hashes','pause_event','remove_from_filename_words_list',
+ 'downloaded_files_lock','downloaded_file_hashes_lock','dynamic_character_filter_holder', 'session_file_path',
+ 'session_lock',
+ 'skip_words_list','skip_words_scope','char_filter_scope',
+ 'show_external_links','extract_links_only','num_file_threads_for_worker',
+ 'start_page','end_page','target_post_id_from_initial_url',
+ 'manga_date_file_counter_ref',
+ 'manga_global_file_counter_ref','manga_date_prefix',
+ 'manga_mode_active','unwanted_keywords','manga_filename_style','scan_content_for_images',
+ 'allow_multipart_download','use_cookie','cookie_text','app_base_dir','selected_cookie_file','override_output_dir'
+ ]
+ args_template ['skip_current_file_flag']=None
+ single_thread_args ={key :args_template [key ]for key in dt_expected_keys if key in args_template }
+ self .start_single_threaded_download (**single_thread_args )
+ except Exception as e :
+ self._update_button_states_and_connections() # Re-enable UI if start fails
+ self .log_signal .emit (f"❌ CRITICAL ERROR preparing download: {e }\n{traceback .format_exc ()}")
+ QMessageBox .critical (self ,"Start Error",f"Failed to start process:\n{e }")
+ self .download_finished (0 ,0 ,False ,[])
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+ return True
+
+ def restore_download(self):
+ """Initiates the download restoration process."""
+ if self._is_download_active():
+ QMessageBox.warning(self, "Busy", "A download is already in progress.")
+ return
+
+ if not self.interrupted_session_data:
+ self.log_signal.emit("❌ No session data to restore.")
+ self._clear_session_and_reset_ui()
+ return
+
+ self.log_signal.emit("🔄 Restoring download session...")
+ # The main start_download function now handles the restore logic
+ self.is_restore_pending = True # Set state to indicate restore is in progress
+ self.start_download(is_restore=True)
+
+ def start_single_threaded_download (self ,**kwargs ):
+ global BackendDownloadThread
+ try :
+ self .download_thread =BackendDownloadThread (**kwargs )
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+ 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 )
+ if hasattr (self .download_thread ,'missed_character_post_signal'):
+ self .download_thread .missed_character_post_signal .connect (self .handle_missed_character_post )
+ if hasattr (self .download_thread ,'retryable_file_failed_signal'):
+
+ if hasattr (self .download_thread ,'file_successfully_downloaded_signal'):
+ self .download_thread .file_successfully_downloaded_signal .connect (self ._handle_actual_file_downloaded )
+ if hasattr (self .download_thread ,'post_processed_for_history_signal'):
+ self .download_thread .post_processed_for_history_signal .connect (self ._add_to_history_candidates )
+ self .download_thread .retryable_file_failed_signal .connect (self ._handle_retryable_file_failure )
+ if hasattr (self .download_thread ,'permanent_file_failed_signal'):
+ self .download_thread .permanent_file_failed_signal .connect (self ._handle_permanent_file_failure_from_thread )
+ self .download_thread .start ()
+ self .log_signal .emit ("✅ Single download thread (for posts) started.")
+ self._update_button_states_and_connections() # Update buttons after thread starts
+ except Exception as e :
+ 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 }")
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+
+ def _show_error_files_dialog (self ):
+ """Shows the dialog with files that were skipped due to errors."""
+ if not self .permanently_failed_files_for_dialog :
+ QMessageBox .information (
+ self ,
+ self ._tr ("no_errors_logged_title","No Errors Logged"),
+ self ._tr ("no_errors_logged_message","No files were recorded as skipped due to errors in the last session or after retries."))
+ return
+ dialog =ErrorFilesDialog (self .permanently_failed_files_for_dialog ,self ,self )
+ dialog .retry_selected_signal .connect (self ._handle_retry_from_error_dialog )
+ dialog .exec_ ()
+ def _handle_retry_from_error_dialog (self ,selected_files_to_retry ):
+ self ._start_failed_files_retry_session (files_to_retry_list =selected_files_to_retry )
+
+ def _handle_retryable_file_failure (self ,list_of_retry_details ):
+ """Appends details of files that failed but might be retryable later."""
+ if list_of_retry_details :
+ self .retryable_failed_files_info .extend (list_of_retry_details )
+
+ def _handle_permanent_file_failure_from_thread (self ,list_of_permanent_failure_details ):
+ """Handles permanently failed files signaled by the single BackendDownloadThread."""
+ if list_of_permanent_failure_details :
+ self .permanently_failed_files_for_dialog .extend (list_of_permanent_failure_details )
+ self .log_signal .emit (f"ℹ️ {len (list_of_permanent_failure_details )} file(s) from single-thread download marked as permanently failed for this session.")
+
+ def _submit_post_to_worker_pool (self ,post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults ):
+ """Helper to prepare and submit a single post processing task to the thread pool."""
+ global PostProcessorWorker
+ if not isinstance (post_data_item ,dict ):
+ self .log_signal .emit (f"⚠️ Skipping invalid post data item (not a dict): {type (post_data_item )}");
+ return False
+
+ 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 =='emitter':worker_init_args [key ]=emitter_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 :
+ self .log_signal .emit (f"❌ CRITICAL ERROR: Missing keys for PostProcessorWorker: {', '.join (missing_keys )}");
+ self .cancellation_event .set ()
+ return False
+
+ 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 )
+ return True
+ else :
+ self .log_signal .emit ("⚠️ Thread pool not available. Cannot submit task.");
+ self .cancellation_event .set ()
+ return False
+ 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 ()
+ return False
+ except RuntimeError :
+ self .log_signal .emit (f"⚠️ RuntimeError submitting task (pool likely shutting down).")
+ self .cancellation_event .set ()
+ return False
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error submitting post {post_data_item .get ('id','N/A')} to worker: {e }")
+ self .cancellation_event .set ()
+ return False
+
+ def _load_ui_from_settings_dict(self, settings: dict):
+ """Populates the UI with values from a settings dictionary."""
+ # Text inputs
+ self.link_input.setText(settings.get('api_url', ''))
+ self.dir_input.setText(settings.get('output_dir', ''))
+ self.character_input.setText(settings.get('character_filter_text', ''))
+ self.skip_words_input.setText(settings.get('skip_words_text', ''))
+ self.remove_from_filename_input.setText(settings.get('remove_words_text', ''))
+ self.custom_folder_input.setText(settings.get('custom_folder_name', ''))
+ self.cookie_text_input.setText(settings.get('cookie_text', ''))
+ if hasattr(self, 'manga_date_prefix_input'):
+ self.manga_date_prefix_input.setText(settings.get('manga_date_prefix', ''))
+
+ # Numeric inputs
+ self.thread_count_input.setText(str(settings.get('num_threads', 4)))
+ self.start_page_input.setText(str(settings.get('start_page', '')) if settings.get('start_page') is not None else '')
+ self.end_page_input.setText(str(settings.get('end_page', '')) if settings.get('end_page') is not None else '')
+
+ # Checkboxes
+ for checkbox_name, key in self.get_checkbox_map().items():
+ checkbox = getattr(self, checkbox_name, None)
+ if checkbox:
+ checkbox.setChecked(settings.get(key, False))
+
+ # Radio buttons
+ if settings.get('only_links'): self.radio_only_links.setChecked(True)
+ else:
+ filter_mode = settings.get('filter_mode', 'all')
+ if filter_mode == 'image': self.radio_images.setChecked(True)
+ elif filter_mode == 'video': self.radio_videos.setChecked(True)
+ elif filter_mode == 'archive': self.radio_only_archives.setChecked(True)
+ elif filter_mode == 'audio' and hasattr(self, 'radio_only_audio'): self.radio_only_audio.setChecked(True)
+ else: self.radio_all.setChecked(True)
+
+ # Toggle button states
+ self.skip_words_scope = settings.get('skip_words_scope', SKIP_SCOPE_POSTS)
+ self.char_filter_scope = settings.get('char_filter_scope', CHAR_SCOPE_TITLE)
+ self.manga_filename_style = settings.get('manga_filename_style', STYLE_POST_TITLE)
+ self.allow_multipart_download_setting = settings.get('allow_multipart_download', False)
+
+ # Update button texts after setting states
+ self._update_skip_scope_button_text()
+ self._update_char_filter_scope_button_text()
+ self._update_manga_filename_style_button_text()
+ self._update_multipart_toggle_button_text()
+
+ def start_multi_threaded_download (self ,num_post_workers ,**kwargs ):
+ global PostProcessorWorker
+ if self .thread_pool is None :
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+ self .thread_pool =ThreadPoolExecutor (max_workers =num_post_workers ,thread_name_prefix ='PostWorker_')
+
+ self .active_futures =[]
+ self .processed_posts_count =0 ;self .total_posts_to_process =0 ;self .download_counter =0 ;self .skip_counter =0
+ self .all_kept_original_filenames =[]
+ self .is_fetcher_thread_running =True
+
+ fetcher_thread =threading .Thread (
+ target =self ._fetch_and_queue_posts ,
+ args =(kwargs ['api_url_input'],kwargs ,num_post_workers ),
+ daemon =True ,
+ name ="PostFetcher"
+ )
+ fetcher_thread .start ()
+ self .log_signal .emit (f"✅ Post fetcher thread started. {num_post_workers } post worker threads initializing...")
+ self._update_button_states_and_connections() # Update buttons after fetcher thread starts
+
+ def _fetch_and_queue_posts (self ,api_url_input_for_fetcher ,worker_args_template ,num_post_workers ):
+ 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 )
+ emitter_for_worker =worker_args_template .get ('emitter')
+
+ is_restore = self.interrupted_session_data is not None
+ if is_restore:
+ all_posts_data = self.interrupted_session_data['download_state']['all_posts_data']
+ processed_ids = set(self.interrupted_session_data['download_state']['processed_post_ids'])
+ posts_to_process = [p for p in all_posts_data if p.get('id') not in processed_ids]
+ self.log_signal.emit(f"Restoring session. {len(posts_to_process)} posts remaining out of {len(all_posts_data)}.")
+ self.total_posts_to_process = len(all_posts_data)
+ self.processed_posts_count = len(processed_ids)
+ self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
+
+ # Re-assign all_posts_data to only what needs processing
+ all_posts_data = posts_to_process
+
+ if not emitter_for_worker :
+ self .log_signal .emit ("❌ CRITICAL ERROR: Emitter (queue) missing for worker in _fetch_and_queue_posts.");
+ self .finished_signal .emit (0 ,0 ,True ,[]);
+ return
+
+ try:
+ self.log_signal.emit(" Fetching post data from API (this may take a moment for large feeds)...")
+ if not is_restore: # Only fetch new data if not restoring
+ post_generator = download_from_api(
+ api_url_input_for_fetcher,
+ logger=lambda msg: self.log_signal.emit(f"[Fetcher] {msg}"),
+ start_page=worker_args_template.get('start_page'),
+ end_page=worker_args_template.get('end_page'),
+ manga_mode=manga_mode_active_for_fetch,
+ cancellation_event=self.cancellation_event,
+ pause_event=worker_args_template.get('pause_event'),
+ use_cookie=worker_args_template.get('use_cookie'),
+ cookie_text=worker_args_template.get('cookie_text'),
+ selected_cookie_file=worker_args_template.get('selected_cookie_file'),
+ app_base_dir=worker_args_template.get('app_base_dir'),
+ manga_filename_style_for_sort_check=(
+ worker_args_template.get('manga_filename_style')
+ if manga_mode_active_for_fetch
+ else None
+ )
+ )
+
+ for posts_batch in post_generator:
+ if self.cancellation_event.is_set():
+ fetch_error_occurred = True; self.log_signal.emit(" Post fetching cancelled by user."); break
+ 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:
+ self.log_signal.emit(f" Fetched {self.total_posts_to_process} posts so far...")
+ else:
+ fetch_error_occurred = True; self.log_signal.emit(f"❌ API fetcher returned non-list type: {type(posts_batch)}"); break
+
+ if not fetch_error_occurred and not self.cancellation_event.is_set():
+ self.log_signal.emit(f"✅ Post fetching complete. Total posts to process: {self.total_posts_to_process}")
+
+ # Get a clean, serializable dictionary of UI settings
+ output_dir_for_session = worker_args_template.get('output_dir', self.dir_input.text().strip())
+ ui_settings_for_session = self._get_current_ui_settings_as_dict(
+ api_url_override=api_url_input_for_fetcher,
+ output_dir_override=output_dir_for_session
+ )
+
+ # Save initial session state
+ session_data = {
+ "timestamp": datetime.datetime.now().isoformat(),
+ "ui_settings": ui_settings_for_session,
+ "download_state": {
+ "all_posts_data": all_posts_data,
+ "processed_post_ids": []
+ }
+ }
+ self._save_session_file(session_data)
+
+ # From here, all_posts_data is the list of posts to process (either new or restored)
+ unique_posts_dict ={}
+ for post in all_posts_data :
+ post_id =post .get ('id')
+ if post_id is not None :
+ if post_id not in unique_posts_dict :
+ unique_posts_dict [post_id ]=post
+ else :
+ self .log_signal .emit (f"⚠️ Skipping post with no ID: {post .get ('title','Untitled')}")
+
+ posts_to_process_final = list(unique_posts_dict.values())
+
+ if not is_restore:
+ self.total_posts_to_process = len(posts_to_process_final)
+ self.log_signal.emit(f" Processed {len(posts_to_process_final)} unique posts after de-duplication.")
+ if len(posts_to_process_final) < len(all_posts_data):
+ self.log_signal.emit(f" Note: {len(all_posts_data) - len(posts_to_process_final)} duplicate post IDs were removed.")
+ all_posts_data = posts_to_process_final
+
+ except TypeError as te :
+ 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
+ except RuntimeError as re_err :
+ self .log_signal .emit (f"ℹ️ Post fetching runtime error (likely cancellation or API issue): {re_err }");fetch_error_occurred =True
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error during post fetching: {e }\n{traceback .format_exc (limit =2 )}");fetch_error_occurred =True
+
+ finally :
+ self .is_fetcher_thread_running =False
+ self .log_signal .emit (f"ℹ️ Post fetcher thread (_fetch_and_queue_posts) has completed its task. is_fetcher_thread_running set to False.")
+
+ if self .cancellation_event .is_set ()or fetch_error_occurred :
+ self .finished_signal .emit (self .download_counter ,self .skip_counter ,self .cancellation_event .is_set (),self .all_kept_original_filenames )
+ if self .thread_pool :self .thread_pool .shutdown (wait =False ,cancel_futures =True );self .thread_pool =None
+ return
+
+ if not all_posts_data:
+ self .log_signal .emit ("😕 No posts found or fetched to process.")
+ self .finished_signal .emit (0 ,0 ,False ,[])
+ return
+
+ self .log_signal .emit (f" Preparing to submit {self .total_posts_to_process } post processing tasks to thread pool...")
+ if not is_restore:
+ self.processed_posts_count = 0
+ self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
+
+ num_file_dl_threads_for_each_worker =worker_args_template .get ('num_file_threads_for_worker',1 )
+
+
+ ppw_expected_keys =[
+ 'post_data','download_root','known_names','filter_character_list','unwanted_keywords',
+ 'filter_mode','skip_zip','skip_rar','use_subfolders','use_post_subfolders',
+ 'target_post_id_from_initial_url','custom_folder_name','compress_images','emitter','pause_event',
+ 'download_thumbnails','service','user_id','api_url_input',
+ 'cancellation_event','downloaded_files','downloaded_file_hashes',
+ 'downloaded_files_lock','downloaded_file_hashes_lock','remove_from_filename_words_list','dynamic_character_filter_holder',
+ 'skip_words_list','skip_words_scope','char_filter_scope',
+ 'show_external_links','extract_links_only','allow_multipart_download','use_cookie','cookie_text',
+ 'app_base_dir','selected_cookie_file','override_output_dir',
+ 'num_file_threads','skip_current_file_flag','manga_date_file_counter_ref','scan_content_for_images',
+ 'manga_mode_active','manga_filename_style','manga_date_prefix',
+ 'manga_global_file_counter_ref'
+ ,'creator_download_folder_ignore_words'
+ , 'session_file_path', 'session_lock'
+ ]
+
+ ppw_optional_keys_with_defaults ={
+ 'skip_words_list','skip_words_scope','char_filter_scope','remove_from_filename_words_list',
+ 'show_external_links','extract_links_only','duplicate_file_mode',
+ 'num_file_threads','skip_current_file_flag','manga_mode_active','manga_filename_style','manga_date_prefix',
+ 'manga_date_file_counter_ref','use_cookie','cookie_text','app_base_dir','selected_cookie_file'
+ }
+ if num_post_workers >POST_WORKER_BATCH_THRESHOLD and self .total_posts_to_process >POST_WORKER_NUM_BATCHES :
+ self .log_signal .emit (f" High thread count ({num_post_workers }) detected. Batching post submissions into {POST_WORKER_NUM_BATCHES } parts.")
+
+ import math
+ tasks_submitted_in_batch_segment =0
+ batch_size =math .ceil (self .total_posts_to_process /POST_WORKER_NUM_BATCHES )
+ submitted_count_in_batching =0
+
+ for batch_num in range (POST_WORKER_NUM_BATCHES ):
+ if self .cancellation_event .is_set ():break
+
+ if self .pause_event and self .pause_event .is_set ():
+ self .log_signal .emit (f" [Fetcher] Batch submission paused before batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }...")
+ while self .pause_event .is_set ():
+ if self .cancellation_event .is_set ():
+ self .log_signal .emit (" [Fetcher] Batch submission cancelled while paused.")
+ break
+ time .sleep (0.5 )
+ if self .cancellation_event .is_set ():break
+ if not self .cancellation_event .is_set ():
+ self .log_signal .emit (f" [Fetcher] Batch submission resumed. Processing batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES }.")
+
+ start_index =batch_num *batch_size
+ end_index =min ((batch_num +1 )*batch_size ,self .total_posts_to_process )
+ current_batch_posts =all_posts_data [start_index :end_index ]
+
+ if not current_batch_posts :continue
+
+ self .log_signal .emit (f" Submitting batch {batch_num +1 }/{POST_WORKER_NUM_BATCHES } ({len (current_batch_posts )} posts) to pool...")
+ for post_data_item in current_batch_posts :
+ if self .cancellation_event .is_set ():break
+ success =self ._submit_post_to_worker_pool (post_data_item ,worker_args_template ,num_file_dl_threads_for_each_worker ,emitter_for_worker ,ppw_expected_keys ,ppw_optional_keys_with_defaults )
+ if success :
+ submitted_count_in_batching +=1
+ tasks_submitted_in_batch_segment +=1
+ if tasks_submitted_in_batch_segment %10 ==0 :
+ time .sleep (0.005 )
+ tasks_submitted_in_batch_segment =0
+ elif self .cancellation_event .is_set ():
+ break
+
+ if self .cancellation_event .is_set ():break
+
+ if batch_num 0 and self .processed_posts_count >=self .total_posts_to_process :
+ if all (f .done ()for f in self .active_futures ):
+ QApplication .processEvents ()
+ self .log_signal .emit ("🏁 All submitted post tasks have completed or failed.")
+ self .finished_signal .emit (self .download_counter ,self .skip_counter ,self .cancellation_event .is_set (),self .all_kept_original_filenames )
+
+ 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 _finalize_download_history (self ):
+ """Processes candidates and selects the final 3 history entries.
+ Only updates final_download_history_entries if new candidates are available.
+ """
+ if not self .download_history_candidates :
+
+
+ self .log_signal .emit ("ℹ️ No new history candidates from this session. Preserving existing history.")
+
+
+ self .download_history_candidates .clear ()
+ return
+
+ candidates =list (self .download_history_candidates )
+ now =datetime .datetime .now (datetime .timezone .utc )
+
+ def get_sort_key (entry ):
+ upload_date_str =entry .get ('upload_date_str')
+ if not upload_date_str :
+ return datetime .timedelta .max
+ try :
+
+ upload_dt =datetime .datetime .fromisoformat (upload_date_str .replace ('Z','+00:00'))
+ if upload_dt .tzinfo is None :
+ upload_dt =upload_dt .replace (tzinfo =datetime .timezone .utc )
+ return abs (now -upload_dt )
+ except ValueError :
+ return datetime .timedelta .max
+
+ candidates .sort (key =get_sort_key )
+ self .final_download_history_entries =candidates [:3 ]
+ self .log_signal .emit (f"ℹ️ Finalized download history: {len (self .final_download_history_entries )} entries selected.")
+ self .download_history_candidates .clear ()
+
+
+ self ._save_persistent_history ()
+
+ def _get_configurable_widgets_on_pause (self ):
+ """Returns a list of widgets that should be re-enabled when paused."""
+ return [
+ self .dir_input ,self .dir_button ,
+ self .character_input ,self .char_filter_scope_toggle_button ,
+ self .skip_words_input ,self .skip_scope_toggle_button ,
+ self .remove_from_filename_input ,
+ self .radio_all ,self .radio_images ,self .radio_videos ,
+ self .radio_only_archives ,self .radio_only_links ,
+ self .skip_zip_checkbox ,self .skip_rar_checkbox ,
+ self .download_thumbnails_checkbox ,self .compress_images_checkbox ,
+ self .use_subfolders_checkbox ,self .use_subfolder_per_post_checkbox ,
+ self .manga_mode_checkbox ,
+ self .manga_rename_toggle_button ,
+ self .cookie_browse_button ,
+ self .favorite_mode_checkbox ,
+ self .multipart_toggle_button ,
+ self .cookie_text_input ,
+ self .scan_content_images_checkbox ,
+ self .use_cookie_checkbox ,
+ self .external_links_checkbox
+ ]
+
+ def set_ui_enabled (self ,enabled ):
+ all_potentially_toggleable_widgets =[
+ self .link_input ,self .dir_input ,self .dir_button ,
+ self .page_range_label ,self .start_page_input ,self .to_label ,self .end_page_input ,
+ self .character_input ,self .char_filter_scope_toggle_button ,self .character_filter_widget ,
+ self .filters_and_custom_folder_container_widget ,
+ self .custom_folder_label ,self .custom_folder_input ,
+ self .skip_words_input ,self .skip_scope_toggle_button ,self .remove_from_filename_input ,
+ self .radio_all ,self .radio_images ,self .radio_videos ,self .radio_only_archives ,self .radio_only_links ,
+ self .skip_zip_checkbox ,self .skip_rar_checkbox ,self .download_thumbnails_checkbox ,self .compress_images_checkbox ,
+ self .use_subfolders_checkbox ,self .use_subfolder_per_post_checkbox ,self .scan_content_images_checkbox ,
+ self .use_multithreading_checkbox ,self .thread_count_input ,self .thread_count_label ,
+ self .favorite_mode_checkbox ,
+ 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
+ ]
+
+ widgets_to_enable_on_pause =self ._get_configurable_widgets_on_pause ()
+ is_fav_mode_active =self .favorite_mode_checkbox .isChecked ()if self .favorite_mode_checkbox else False
+ download_is_active_or_paused =not enabled
+
+ if not enabled :
+ if self .bottom_action_buttons_stack :
+ self .bottom_action_buttons_stack .setCurrentIndex (0 )
+
+ if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
+ self .log_signal .emit ("ℹ️ Cancelling active Mega download due to UI state change.")
+ self .external_link_download_thread .cancel ()
+ else :
+ pass
+
+
+ for widget in all_potentially_toggleable_widgets :
+ if not widget :continue
+
+
+ if widget is self .favorite_mode_artists_button or widget is self .favorite_mode_posts_button :continue
+ elif self .is_paused and widget in widgets_to_enable_on_pause :
+ widget .setEnabled (True )
+ elif widget is self .favorite_mode_checkbox :
+ widget .setEnabled (enabled )
+ elif widget is self .use_cookie_checkbox and is_fav_mode_active :
+ widget .setEnabled (False )
+ elif widget is self .use_cookie_checkbox and self .is_paused and widget in widgets_to_enable_on_pause :
+ widget .setEnabled (True )
+ else :
+ widget .setEnabled (enabled )
+
+ if self .link_input :
+ self .link_input .setEnabled (enabled and not is_fav_mode_active )
+
+
+
+ if not enabled :
+ if self .favorite_mode_artists_button :
+ self .favorite_mode_artists_button .setEnabled (False )
+ if self .favorite_mode_posts_button :
+ self .favorite_mode_posts_button .setEnabled (False )
+
+ if self .download_btn :
+ self .download_btn .setEnabled (enabled and not is_fav_mode_active )
+
+
+ if self .external_links_checkbox :
+ 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 ()
+ is_only_audio =hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()
+ can_enable_ext_links =enabled and not is_only_links and not is_only_archives and not is_only_audio
+ self .external_links_checkbox .setEnabled (can_enable_ext_links )
+ if self .is_paused and not is_only_links and not is_only_archives and not is_only_audio :
+ self .external_links_checkbox .setEnabled (True )
+ if hasattr (self ,'use_cookie_checkbox'):
+ self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ())
+
+ if self .log_verbosity_toggle_button :self .log_verbosity_toggle_button .setEnabled (True )
+
+ multithreading_currently_on =self .use_multithreading_checkbox .isChecked ()
+ if self .thread_count_input :self .thread_count_input .setEnabled (enabled and multithreading_currently_on )
+ if self .thread_count_label :self .thread_count_label .setEnabled (enabled and multithreading_currently_on )
+
+ subfolders_currently_on =self .use_subfolders_checkbox .isChecked ()
+ if self .use_subfolder_per_post_checkbox :
+ self .use_subfolder_per_post_checkbox .setEnabled (enabled or (self .is_paused and self .use_subfolder_per_post_checkbox in widgets_to_enable_on_pause ))
+ if self .cancel_btn :self .cancel_btn .setEnabled (download_is_active_or_paused )
+ if self .pause_btn :
+ self .pause_btn .setEnabled (download_is_active_or_paused )
+ if download_is_active_or_paused :
+ 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 .setToolTip (self ._tr ("resume_download_button_tooltip","Click to resume the download.")if self .is_paused else self ._tr ("pause_download_button_tooltip","Click to pause the download."))
+ else :
+ self .pause_btn .setText (self ._tr ("pause_download_button_text","⏸️ Pause Download"))
+ self .pause_btn .setToolTip (self ._tr ("pause_download_button_tooltip","Click to pause the ongoing download process."))
+ self .is_paused =False
+ if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_button_text","❌ Cancel & Reset UI"))
+ if enabled :
+ if self .pause_event :self .pause_event .clear ()
+ if enabled or self .is_paused :
+ self ._handle_multithreading_toggle (multithreading_currently_on )
+ self .update_ui_for_manga_mode (self .manga_mode_checkbox .isChecked ()if self .manga_mode_checkbox else False )
+ self .update_custom_folder_visibility (self .link_input .text ())
+ self .update_page_range_enabled_state ()
+ if self .radio_group and self .radio_group .checkedButton ():
+ self ._handle_filter_mode_change (self .radio_group .checkedButton (),True )
+ self .update_ui_for_subfolders (subfolders_currently_on )
+ self ._handle_favorite_mode_toggle (is_fav_mode_active )
+
+ def _handle_pause_resume_action (self ):
+ if self ._is_download_active ():
+ self .is_paused =not self .is_paused
+ if self .is_paused :
+ if self .pause_event :self .pause_event .set ()
+ self .log_signal .emit ("ℹ️ Download paused by user. Some settings can now be changed for subsequent operations.")
+ else :
+ if self .pause_event :self .pause_event .clear ()
+ self .log_signal .emit ("ℹ️ Download resumed by user.")
+ self .set_ui_enabled (False )
+
+ 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...")
+ 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 ();
+ 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 );
+ 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 )
+ if self .manga_mode_checkbox :self .manga_mode_checkbox .setChecked (False )
+ if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setChecked (self .use_cookie_setting )
+ if not (hasattr (self ,'use_cookie_checkbox')and self .use_cookie_checkbox .isChecked ()):
+ self .selected_cookie_filepath =None
+ if hasattr (self ,'cookie_text_input'):self .cookie_text_input .setText (self .cookie_text_setting if self .use_cookie_setting else "")
+ self .allow_multipart_download_setting =False
+ self ._update_multipart_toggle_button_text ()
+
+ self .skip_words_scope =SKIP_SCOPE_POSTS
+ self ._update_skip_scope_button_text ()
+
+ if hasattr (self ,'manga_date_prefix_input'):self .manga_date_prefix_input .clear ()
+
+ self .char_filter_scope =CHAR_SCOPE_TITLE
+ self ._update_char_filter_scope_button_text ()
+
+ self .manga_filename_style =STYLE_POST_TITLE
+ self ._update_manga_filename_style_button_text ()
+ if preserve_url is not None :
+ self .link_input .setText (preserve_url )
+ if preserve_dir is not None :
+ self .dir_input .setText (preserve_dir )
+ self .external_link_queue .clear ();self .extracted_links_cache =[]
+ self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
+ if self .pause_event :self .pause_event .clear ()
+ self.is_restore_pending = False
+ 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 .is_paused =False
+ self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ())
+
+ self._update_button_states_and_connections() # Reset button states and connections
+ self .favorite_download_queue .clear ()
+ self .is_processing_favorites_queue =False
+
+ self .only_links_log_display_mode =LOG_DISPLAY_LINKS
+
+ if hasattr (self ,'link_input'):
+ if self .download_extracted_links_button :
+ self .download_extracted_links_button .setEnabled (False )
+
+ self .last_link_input_text_for_queue_sync =self .link_input .text ()
+ self .permanently_failed_files_for_dialog .clear ()
+ self .filter_character_list (self .character_search_input .text ())
+ self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
+ self ._update_favorite_scope_button_text ()
+
+ self .set_ui_enabled (True )
+ self.interrupted_session_data = None # Clear session data from memory
+ self .update_custom_folder_visibility (self .link_input .text ())
+ self .update_page_range_enabled_state ()
+ self ._update_cookie_input_visibility (self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False )
+ if hasattr (self ,'favorite_mode_checkbox'):
+ self ._handle_favorite_mode_toggle (False )
+
+ self .log_signal .emit ("✅ Soft UI reset complete. Preserved URL and Directory (if provided).")
+
+ def _update_log_display_mode_button_text (self ):
+ if hasattr (self ,'log_display_mode_toggle_button'):
+ if self .only_links_log_display_mode ==LOG_DISPLAY_LINKS :
+ self .log_display_mode_toggle_button .setText (self ._tr ("log_display_mode_links_view_text","🔗 Links View"))
+ self .log_display_mode_toggle_button .setToolTip (
+ "Current View: Extracted Links.\n"
+ "After Mega download, Mega log is shown THEN links are appended.\n"
+ "Click to switch to 'Download Progress View'."
+ )
+ else :
+ self .log_display_mode_toggle_button .setText (self ._tr ("log_display_mode_progress_view_text","⬇️ Progress View"))
+ self .log_display_mode_toggle_button .setToolTip (
+ "Current View: Mega Download Progress.\n"
+ "After Mega download, ONLY Mega log is shown (links hidden).\n"
+ "Click to switch to 'Extracted Links View'."
+ )
+
+ def _toggle_log_display_mode (self ):
+ self .only_links_log_display_mode =LOG_DISPLAY_DOWNLOAD_PROGRESS if self .only_links_log_display_mode ==LOG_DISPLAY_LINKS else LOG_DISPLAY_LINKS
+ self ._update_log_display_mode_button_text ()
+ self ._filter_links_log ()
+
+ def cancel_download_button_action (self ):
+ 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
+ self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...")
+
+ self._clear_session_file() # Clear session file on explicit cancel
+ if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
+ self .log_signal .emit (" Cancelling active External Link download thread...")
+ self .external_link_download_thread .cancel ()
+
+ current_url =self .link_input .text ()
+ current_dir =self .dir_input .text ()
+
+ self .cancellation_event .set ()
+ self .is_fetcher_thread_running =False
+ if self .download_thread and self .download_thread .isRunning ():self .download_thread .requestInterruption ();self .log_signal .emit (" Signaled single download thread to interrupt.")
+ 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
+ self .active_futures =[]
+
+ self .external_link_queue .clear ();self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
+
+ self ._perform_soft_ui_reset (preserve_url =current_url ,preserve_dir =current_dir )
+
+ self .progress_label .setText (f"{self ._tr ('status_cancelled_by_user','Cancelled by user')}. {self ._tr ('ready_for_new_task_text','Ready for new task.')}")
+ self .file_progress_label .setText ("")
+ if self .pause_event :self .pause_event .clear ()
+ self .log_signal .emit ("ℹ️ UI reset. Ready for new operation. Background tasks are being terminated.")
+ self .is_paused =False
+ if hasattr (self ,'retryable_failed_files_info')and self .retryable_failed_files_info :
+ self .log_signal .emit (f" Discarding {len (self .retryable_failed_files_info )} pending retryable file(s) due to cancellation.")
+ self .cancellation_message_logged_this_session =False
+ self .retryable_failed_files_info .clear ()
+ self .favorite_download_queue .clear ()
+ self .permanently_failed_files_for_dialog .clear ()
+ self .is_processing_favorites_queue =False
+ self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
+ self ._update_favorite_scope_button_text ()
+ if hasattr (self ,'link_input'):
+ self .last_link_input_text_for_queue_sync =self .link_input .text ()
+ self .cancellation_message_logged_this_session =False
+
+ def _get_domain_for_service (self ,service_name :str )->str :
+ """Determines the base domain for a given service."""
+ if not isinstance (service_name ,str ):
+ return "kemono.su"
+ service_lower =service_name .lower ()
+ coomer_primary_services ={'onlyfans','fansly','manyvids','candfans','gumroad','patreon','subscribestar','dlsite','discord','fantia','boosty','pixiv','fanbox'}
+ if service_lower in coomer_primary_services and service_lower not in ['patreon','discord','fantia','boosty','pixiv','fanbox']:
+ return "coomer.su"
+ return "kemono.su"
+
+ 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 =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 =[]
+
+ if not cancelled_by_user and not self.retryable_failed_files_info:
+ self._clear_session_file()
+ self.interrupted_session_data = None
+ self.is_restore_pending = False
+
+ self ._finalize_download_history ()
+ status_message =self ._tr ("status_cancelled_by_user","Cancelled by user")if cancelled_by_user else self ._tr ("status_completed","Completed")
+ if cancelled_by_user and self .retryable_failed_files_info :
+ self .log_signal .emit (f" Download cancelled, discarding {len (self .retryable_failed_files_info )} file(s) that were pending retry.")
+ self .retryable_failed_files_info .clear ()
+
+ 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 )
+
+ if kept_original_names_list :
+ intro_msg =(
+ HTML_PREFIX +
+ "ℹ️ The following files from multi-file manga posts "
+ "(after the first file) kept their original names:
"
+ )
+ self .log_signal .emit (intro_msg )
+
+ html_list_items =""
+ for name in kept_original_names_list :
+ html_list_items +=f"- {name }
"
+ html_list_items +="
"
+
+ self .log_signal .emit (HTML_PREFIX +html_list_items )
+ self .log_signal .emit ("="*40 )
+
+ if self .download_thread :
+ try :
+ if hasattr (self .download_thread ,'progress_signal'):self .download_thread .progress_signal .disconnect (self .handle_main_log )
+ if hasattr (self .download_thread ,'add_character_prompt_signal'):self .download_thread .add_character_prompt_signal .disconnect (self .add_character_prompt_signal )
+ if hasattr (self .download_thread ,'finished_signal'):self .download_thread .finished_signal .disconnect (self .download_finished )
+ 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 )
+ if hasattr (self .download_thread ,'missed_character_post_signal'):
+ self .download_thread .missed_character_post_signal .disconnect (self .handle_missed_character_post )
+ if hasattr (self .download_thread ,'retryable_file_failed_signal'):
+ self .download_thread .retryable_file_failed_signal .disconnect (self ._handle_retryable_file_failure )
+ if hasattr (self .download_thread ,'file_successfully_downloaded_signal'):
+ self .download_thread .file_successfully_downloaded_signal .disconnect (self ._handle_actual_file_downloaded )
+ if hasattr (self .download_thread ,'post_processed_for_history_signal'):
+ self .download_thread .post_processed_for_history_signal .disconnect (self ._add_to_history_candidates )
+ except (TypeError ,RuntimeError )as e :
+ self .log_signal .emit (f"ℹ️ Note during single-thread signal disconnection: {e }")
+
+ if not self .download_thread .isRunning ():
+
+ if self .download_thread :
+ self .download_thread .deleteLater ()
+ self .download_thread =None
+
+ self .progress_label .setText (
+ f"{status_message }: "
+ f"{total_downloaded } {self ._tr ('files_downloaded_label','downloaded')}, "
+ f"{total_skipped } {self ._tr ('files_skipped_label','skipped')}."
+ )
+ self .file_progress_label .setText ("")
+ if not cancelled_by_user :self ._try_process_next_external_link ()
+
+ 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
+
+ self .active_futures =[]
+ if self .pause_event :self .pause_event .clear ()
+ self .cancel_btn .setEnabled (False )
+ self .is_paused =False
+ if not cancelled_by_user and self .retryable_failed_files_info :
+ num_failed =len (self .retryable_failed_files_info )
+ reply =QMessageBox .question (self ,"Retry Failed Downloads?",
+ f"{num_failed } file(s) failed with potentially recoverable errors (e.g., IncompleteRead).\n\n"
+ "Would you like to attempt to download these failed files again?",
+ QMessageBox .Yes |QMessageBox .No ,QMessageBox .Yes )
+ if reply ==QMessageBox .Yes :
+ self ._start_failed_files_retry_session ()
+ return
+ else :
+ self .log_signal .emit ("ℹ️ User chose not to retry failed files.")
+ self .permanently_failed_files_for_dialog .extend (self .retryable_failed_files_info )
+ if self .permanently_failed_files_for_dialog :
+ self .log_signal .emit (f"🆘 Error button enabled. {len (self .permanently_failed_files_for_dialog )} file(s) can be viewed.")
+ self .cancellation_message_logged_this_session =False
+ self .retryable_failed_files_info .clear ()
+
+ self .is_fetcher_thread_running =False
+
+ if self .is_processing_favorites_queue :
+ if not self .favorite_download_queue :
+ self .is_processing_favorites_queue =False
+ self .log_signal .emit (f"✅ All {self .current_processing_favorite_item_info .get ('type','item')} downloads from favorite queue have been processed.")
+ self .set_ui_enabled (not self ._is_download_active ())
+ else :
+ self ._process_next_favorite_download ()
+ else :
+ self .set_ui_enabled (True )
+ self .cancellation_message_logged_this_session =False
+
+ def _handle_thumbnail_mode_change (self ,thumbnails_checked ):
+ """Handles UI changes when 'Download Thumbnails Only' is toggled."""
+ if not hasattr (self ,'scan_content_images_checkbox'):
+ return
+
+ if thumbnails_checked :
+ self .scan_content_images_checkbox .setChecked (True )
+ self .scan_content_images_checkbox .setEnabled (False )
+ self .scan_content_images_checkbox .setToolTip (
+ "Automatically enabled and locked because 'Download Thumbnails Only' is active.\n"
+ "In this mode, only images found by content scanning will be downloaded."
+ )
+ else :
+ self .scan_content_images_checkbox .setEnabled (True )
+ self .scan_content_images_checkbox .setChecked (False )
+ self .scan_content_images_checkbox .setToolTip (self ._original_scan_content_tooltip )
+
+ def _start_failed_files_retry_session (self ,files_to_retry_list =None ):
+ if files_to_retry_list :
+ self .files_for_current_retry_session =list (files_to_retry_list )
+ self .permanently_failed_files_for_dialog =[f for f in self .permanently_failed_files_for_dialog if f not in files_to_retry_list ]
+ else :
+ self .files_for_current_retry_session =list (self .retryable_failed_files_info )
+ self .retryable_failed_files_info .clear ()
+ self .log_signal .emit (f"🔄 Starting retry session for {len (self .files_for_current_retry_session )} file(s)...")
+ self .set_ui_enabled (False )
+ if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_retry_button_text","❌ Cancel Retry"))
+
+
+ self .active_retry_futures =[]
+ self .processed_retry_count =0
+ self .succeeded_retry_count =0
+ self .failed_retry_count_in_session =0
+ self .total_files_for_retry =len (self .files_for_current_retry_session )
+ self .active_retry_futures_map ={}
+
+ self .progress_label .setText (self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =0 ,total_posts =self .total_files_for_retry ,progress_percent =0.0 ).replace ("posts","files"))
+ self .cancellation_event .clear ()
+
+ num_retry_threads =1
+ try :
+ num_threads_from_gui =int (self .thread_count_input .text ().strip ())
+ num_retry_threads =max (1 ,min (num_threads_from_gui ,MAX_FILE_THREADS_PER_POST_OR_WORKER ,self .total_files_for_retry if self .total_files_for_retry >0 else 1 ))
+ except ValueError :
+ num_retry_threads =1
+
+ self .retry_thread_pool =ThreadPoolExecutor (max_workers =num_retry_threads ,thread_name_prefix ='RetryFile_')
+ common_ppw_args_for_retry ={
+ 'download_root':self .dir_input .text ().strip (),
+ 'known_names':list (KNOWN_NAMES ),
+ 'emitter':self .worker_to_gui_queue ,
+ 'unwanted_keywords':{'spicy','hd','nsfw','4k','preview','teaser','clip'},
+ 'filter_mode':self .get_filter_mode (),
+ 'skip_zip':self .skip_zip_checkbox .isChecked (),
+ 'skip_rar':self .skip_rar_checkbox .isChecked (),
+ 'use_subfolders':self .use_subfolders_checkbox .isChecked (),
+ 'use_post_subfolders':self .use_subfolder_per_post_checkbox .isChecked (),
+ 'compress_images':self .compress_images_checkbox .isChecked (),
+ 'download_thumbnails':self .download_thumbnails_checkbox .isChecked (),
+ '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 ,
+ '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 (),
+ '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 ()]if hasattr (self ,'remove_from_filename_input')else [],
+ 'allow_multipart_download':self .allow_multipart_download_setting ,
+ 'filter_character_list':None ,
+ 'dynamic_character_filter_holder':None ,
+ 'target_post_id_from_initial_url':None ,
+ 'custom_folder_name':None ,
+ 'num_file_threads':1 ,
+ 'manga_date_file_counter_ref':None ,
+ }
+
+ for job_details in self .files_for_current_retry_session :
+ future =self .retry_thread_pool .submit (self ._execute_single_file_retry ,job_details ,common_ppw_args_for_retry )
+ future .add_done_callback (self ._handle_retry_future_result )
+ self .active_retry_futures_map [future ]=job_details
+ self .active_retry_futures .append (future )
+
+ def _execute_single_file_retry (self ,job_details ,common_args ):
+ """Executes a single file download retry attempt."""
+ dummy_post_data ={'id':job_details ['original_post_id_for_log'],'title':job_details ['post_title']}
+
+ ppw_init_args ={
+ **common_args ,
+ 'post_data':dummy_post_data ,
+ 'service':job_details .get ('service','unknown_service'),
+ 'user_id':job_details .get ('user_id','unknown_user'),
+ 'api_url_input':job_details .get ('api_url_input',''),
+ 'manga_mode_active':job_details .get ('manga_mode_active_for_file',False ),
+ 'manga_filename_style':job_details .get ('manga_filename_style_for_file',STYLE_POST_TITLE ),
+ 'scan_content_for_images':common_args .get ('scan_content_for_images',False ),
+ 'use_cookie':common_args .get ('use_cookie',False ),
+ 'cookie_text':common_args .get ('cookie_text',""),
+ 'selected_cookie_file':common_args .get ('selected_cookie_file',None ),
+ 'app_base_dir':common_args .get ('app_base_dir',None ),
+ }
+ worker =PostProcessorWorker (**ppw_init_args )
+
+ dl_count ,skip_count ,filename_saved ,original_kept ,status ,_ =worker ._download_single_file (
+ file_info =job_details ['file_info'],
+ target_folder_path =job_details ['target_folder_path'],
+ headers =job_details ['headers'],
+ original_post_id_for_log =job_details ['original_post_id_for_log'],
+ skip_event =None ,
+ post_title =job_details ['post_title'],
+ file_index_in_post =job_details ['file_index_in_post'],
+ num_files_in_this_post =job_details ['num_files_in_this_post'],
+ forced_filename_override =job_details .get ('forced_filename_override')
+ )
+
+
+
+ is_successful_download =(status ==FILE_DOWNLOAD_STATUS_SUCCESS )
+ is_resolved_as_skipped =(status ==FILE_DOWNLOAD_STATUS_SKIPPED )
+
+ return is_successful_download or is_resolved_as_skipped
+
+ def _handle_retry_future_result (self ,future ):
+ self .processed_retry_count +=1
+ was_successful =False
+ try :
+ if future .cancelled ():
+ self .log_signal .emit (" A retry task was cancelled.")
+ elif future .exception ():
+ self .log_signal .emit (f"❌ Retry task worker error: {future .exception ()}")
+ else :
+ was_successful =future .result ()
+ job_details =self .active_retry_futures_map .pop (future ,None )
+ if was_successful :
+ self .succeeded_retry_count +=1
+ else :
+ self .failed_retry_count_in_session +=1
+ if job_details :
+ self .permanently_failed_files_for_dialog .append (job_details )
+ except Exception as e :
+ self .log_signal .emit (f"❌ Error in _handle_retry_future_result: {e }")
+ self .failed_retry_count_in_session +=1
+
+ progress_percent_retry =(self .processed_retry_count /self .total_files_for_retry *100 )if self .total_files_for_retry >0 else 0
+ self .progress_label .setText (
+ self ._tr ("progress_posts_text","Progress: {processed_posts} / {total_posts} posts ({progress_percent:.1f}%)").format (processed_posts =self .processed_retry_count ,total_posts =self .total_files_for_retry ,progress_percent =progress_percent_retry ).replace ("posts","files")+
+ f" ({self ._tr ('succeeded_text','Succeeded')}: {self .succeeded_retry_count }, {self ._tr ('failed_text','Failed')}: {self .failed_retry_count_in_session })"
+ )
+
+ if self .processed_retry_count >=self .total_files_for_retry :
+ if all (f .done ()for f in self .active_retry_futures ):
+ QTimer .singleShot (0 ,self ._retry_session_finished )
+
+
+ def _retry_session_finished (self ):
+ self .log_signal .emit ("🏁 Retry session finished.")
+ self .log_signal .emit (f" Summary: {self .succeeded_retry_count } Succeeded, {self .failed_retry_count_in_session } Failed.")
+
+ if self .retry_thread_pool :
+ self .retry_thread_pool .shutdown (wait =True )
+ self .retry_thread_pool =None
+
+ if self .external_link_download_thread and not self .external_link_download_thread .isRunning ():
+ self .external_link_download_thread .deleteLater ()
+ self .external_link_download_thread =None
+
+ self .active_retry_futures .clear ()
+ self .active_retry_futures_map .clear ()
+ self .files_for_current_retry_session .clear ()
+
+ if self .permanently_failed_files_for_dialog :
+ self .log_signal .emit (f"🆘 {self ._tr ('error_button_text','Error')} button enabled. {len (self .permanently_failed_files_for_dialog )} file(s) ultimately failed and can be viewed.")
+
+ self .set_ui_enabled (not self ._is_download_active ())
+ if self .cancel_btn :self .cancel_btn .setText (self ._tr ("cancel_button_text","❌ Cancel & Reset UI"))
+ self .progress_label .setText (
+ f"{self ._tr ('retry_finished_text','Retry Finished')}. "
+ f"{self ._tr ('succeeded_text','Succeeded')}: {self .succeeded_retry_count }, "
+ f"{self ._tr ('failed_text','Failed')}: {self .failed_retry_count_in_session }. "
+ f"{self ._tr ('ready_for_new_task_text','Ready for new task.')}")
+ self .file_progress_label .setText ("")
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+
+ def toggle_active_log_view (self ):
+ if self .current_log_view =='progress':
+ self .current_log_view ='missed_character'
+ if self .log_view_stack :self .log_view_stack .setCurrentIndex (1 )
+ if self .log_verbosity_toggle_button :
+ self .log_verbosity_toggle_button .setText (self .CLOSED_EYE_ICON )
+ self .log_verbosity_toggle_button .setToolTip ("Current View: Missed Character Log. Click to switch to Progress Log.")
+ if self .progress_log_label :self .progress_log_label .setText (self ._tr ("missed_character_log_label_text","🚫 Missed Character Log:"))
+ else :
+ self .current_log_view ='progress'
+ if self .log_view_stack :self .log_view_stack .setCurrentIndex (0 )
+ if self .log_verbosity_toggle_button :
+ self .log_verbosity_toggle_button .setText (self .EYE_ICON )
+ self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.")
+ 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 ():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 .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 )
+ if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
+ if self .log_verbosity_toggle_button :
+ self .log_verbosity_toggle_button .setText (self .EYE_ICON )
+ self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.")
+
+ 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 (self ._tr ("progress_idle_text","Progress: Idle"));self .file_progress_label .setText ("")
+ with self .downloaded_files_lock :count =len (self .downloaded_files );self .downloaded_files .clear ();
+ self .missed_title_key_terms_count .clear ()
+ self .missed_title_key_terms_examples .clear ()
+ self .logged_summary_for_key_term .clear ()
+ self .already_logged_bold_key_terms .clear ()
+ self .missed_key_terms_buffer .clear ()
+ self .favorite_download_queue .clear ()
+ self .only_links_log_display_mode =LOG_DISPLAY_LINKS
+ self .mega_download_log_preserved_once =False
+ self .permanently_failed_files_for_dialog .clear ()
+ self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
+ self ._update_favorite_scope_button_text ()
+ self .retryable_failed_files_info .clear ()
+ self .cancellation_message_logged_this_session =False
+ self .is_processing_favorites_queue =False
+
+ 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 ()
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+ self .manga_filename_style =STYLE_POST_TITLE
+ self .settings .setValue (MANGA_FILENAME_STYLE_KEY ,self .manga_filename_style )
+
+ self .skip_words_scope =SKIP_SCOPE_POSTS
+ self .settings .setValue (SKIP_WORDS_SCOPE_KEY ,self .skip_words_scope )
+ self ._update_skip_scope_button_text ()
+
+ self .char_filter_scope =CHAR_SCOPE_TITLE
+ self ._update_char_filter_scope_button_text ()
+
+ 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 )
+
+ def _reset_ui_to_defaults (self ):
+ 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 ();
+ 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 );
+ if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False )
+ self .external_links_checkbox .setChecked (False )
+ if self .manga_mode_checkbox :self .manga_mode_checkbox .setChecked (False )
+ if hasattr (self ,'use_cookie_checkbox'):self .use_cookie_checkbox .setChecked (False )
+ self .selected_cookie_filepath =None
+
+ if hasattr (self ,'cookie_text_input'):self .cookie_text_input .clear ()
+ self .missed_title_key_terms_count .clear ()
+ self .missed_title_key_terms_examples .clear ()
+ self .logged_summary_for_key_term .clear ()
+ self .already_logged_bold_key_terms .clear ()
+ if hasattr (self ,'manga_date_prefix_input'):self .manga_date_prefix_input .clear ()
+ if self .pause_event :self .pause_event .clear ()
+ self .is_paused =False
+ self .missed_key_terms_buffer .clear ()
+ if self .download_extracted_links_button :
+ self .only_links_log_display_mode =LOG_DISPLAY_LINKS
+ self .cancellation_message_logged_this_session =False
+ self .mega_download_log_preserved_once =False
+ self .download_extracted_links_button .setEnabled (False )
+
+ if self .missed_character_log_output :self .missed_character_log_output .clear ()
+
+ self .permanently_failed_files_for_dialog .clear ()
+ self .allow_multipart_download_setting =False
+ self ._update_multipart_toggle_button_text ()
+
+ 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 ()
+
+ self .current_log_view ='progress'
+ self ._update_cookie_input_visibility (False );self ._update_cookie_input_placeholders_and_tooltips ()
+ if self .log_view_stack :self .log_view_stack .setCurrentIndex (0 )
+ if self .progress_log_label :self .progress_log_label .setText ("📜 Progress Log:")
+ if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
+ self ._handle_filter_mode_change (self .radio_all ,True )
+ self ._handle_multithreading_toggle (self .use_multithreading_checkbox .isChecked ())
+ self .filter_character_list ("")
+
+ self .download_btn .setEnabled (True );self .cancel_btn .setEnabled (False )
+ if self .reset_button :self .reset_button .setEnabled (True );self .reset_button .setText (self ._tr ("reset_button_text","🔄 Reset"));self .reset_button .setToolTip (self ._tr ("reset_button_tooltip","Reset all inputs and logs to default state (only when idle)."))
+ if self .log_verbosity_toggle_button :
+ self .log_verbosity_toggle_button .setText (self .EYE_ICON )
+ self .log_verbosity_toggle_button .setToolTip ("Current View: Progress Log. Click to switch to Missed Character Log.")
+ self ._update_manga_filename_style_button_text ()
+ self .update_ui_for_manga_mode (False )
+ if hasattr (self ,'favorite_mode_checkbox'):
+ self ._handle_favorite_mode_toggle (False )
+ if hasattr (self ,'scan_content_images_checkbox'):
+ self .scan_content_images_checkbox .setChecked (False )
+ if hasattr (self ,'download_thumbnails_checkbox'):
+ self ._handle_thumbnail_mode_change (self .download_thumbnails_checkbox .isChecked ())
+
+ def _show_feature_guide (self ):
+ steps_content_keys =[
+ ("help_guide_step1_title","help_guide_step1_content"),
+ ("help_guide_step2_title","help_guide_step2_content"),
+ ("help_guide_step3_title","help_guide_step3_content"),
+ ("help_guide_step4_title","help_guide_step4_content"),
+ ("help_guide_step5_title","help_guide_step5_content"),
+ ("help_guide_step6_title","help_guide_step6_content"),
+ ("help_guide_step7_title","help_guide_step7_content"),
+ ("help_guide_step8_title","help_guide_step8_content"),
+ ("help_guide_step9_title","help_guide_step9_content"),
+ ("column_header_post_title","Post Title"),
+ ("column_header_date_uploaded","Date Uploaded"),
+ ]
+
+ steps =[
+ ]
+ for title_key ,content_key in steps_content_keys :
+ title =self ._tr (title_key ,title_key )
+ content =self ._tr (content_key ,f"Content for {content_key } not found.")
+ steps .append ((title ,content ))
+
+ guide_dialog =HelpGuideDialog (steps ,self )
+ guide_dialog .exec_ ()
+
+ def prompt_add_character (self ,character_name ):
+ global KNOWN_NAMES
+ 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 )
+ result =(reply ==QMessageBox .Yes )
+ if result :
+ if self .add_new_character (name_to_add =character_name ,
+ is_group_to_add =False ,
+ aliases_to_add =[character_name ],
+ suppress_similarity_prompt =False ):
+ 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, failed, or a similar name conflict was not overridden.")
+ self .character_prompt_response_signal .emit (result )
+
+ def receive_add_character_result (self ,result ):
+ with QMutexLocker (self .prompt_mutex ):self ._add_character_response =result
+ 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'}")
+
+ def _update_multipart_toggle_button_text (self ):
+ if hasattr (self ,'multipart_toggle_button'):
+ if self .allow_multipart_download_setting :
+ self .multipart_toggle_button .setText (self ._tr ("multipart_on_button_text","Multi-part: ON"))
+ self .multipart_toggle_button .setToolTip (self ._tr ("multipart_on_button_tooltip","Tooltip for multipart ON"))
+ else :
+ self .multipart_toggle_button .setText (self ._tr ("multipart_off_button_text","Multi-part: OFF"))
+ self .multipart_toggle_button .setToolTip (self ._tr ("multipart_off_button_tooltip","Tooltip for multipart OFF"))
+
+ def _toggle_multipart_mode (self ):
+ 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 (
+ "Multi-part download advisory:
"
+ ""
+ "- Best suited for large files (e.g., single post videos).
"
+ "- When downloading a full creator feed with many small files (like images):"
+ "
- May not offer significant speed benefits.
"
+ "- Could potentially make the UI feel choppy.
"
+ "- May spam the process log with rapid, numerous small download messages.
"
+ "- Consider using the 'Videos' filter if downloading a creator feed to primarily target large files for multi-part.
"
+ "
"
+ "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 )
+ msg_box .exec_ ()
+
+ if msg_box .clickedButton ()==cancel_button :
+ self .log_signal .emit ("ℹ️ Multi-part download enabling cancelled by user.")
+ return
+
+ self .allow_multipart_download_setting =not self .allow_multipart_download_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 _open_known_txt_file (self ):
+ if not os .path .exists (self .config_file ):
+ QMessageBox .warning (self ,"File Not Found",
+ f"The file 'Known.txt' was not found at:\n{self .config_file }\n\n"
+ "It will be created automatically when you add a known name or close the application.")
+ self .log_signal .emit (f"ℹ️ 'Known.txt' not found at {self .config_file }. It will be created later.")
+ return
+
+ try :
+ if sys .platform =="win32":
+ os .startfile (self .config_file )
+ elif sys .platform =="darwin":
+ subprocess .call (['open',self .config_file ])
+ else :
+ subprocess .call (['xdg-open',self .config_file ])
+ self .log_signal .emit (f"ℹ️ Attempted to open '{os .path .basename (self .config_file )}' with the default editor.")
+ except FileNotFoundError :
+ QMessageBox .critical (self ,"Error",f"Could not find '{os .path .basename (self .config_file )}' at {self .config_file } to open it.")
+ self .log_signal .emit (f"❌ Error: '{os .path .basename (self .config_file )}' not found at {self .config_file } when trying to open.")
+ except Exception as e :
+ QMessageBox .critical (self ,"Error Opening File",f"Could not open '{os .path .basename (self .config_file )}':\n{e }")
+ self .log_signal .emit (f"❌ Error opening '{os .path .basename (self .config_file )}': {e }")
+
+ def _show_add_to_filter_dialog (self ):
+ global KNOWN_NAMES
+ if not KNOWN_NAMES :
+ QMessageBox .information (self ,"No Known Names","Your 'Known.txt' list is empty. Add some names first.")
+ return
+
+ dialog =KnownNamesFilterDialog (KNOWN_NAMES ,self ,self )
+ if dialog .exec_ ()==QDialog .Accepted :
+ selected_entries =dialog .get_selected_entries ()
+ if selected_entries :
+ self ._add_names_to_character_filter_input (selected_entries )
+
+ def _add_names_to_character_filter_input (self ,selected_entries ):
+ """
+ Adds the selected known name entries to the character filter input field.
+ """
+ if not selected_entries :
+ return
+
+ names_to_add_str_list =[]
+ for entry in selected_entries :
+ if entry .get ("is_group"):
+ aliases_str =", ".join (entry .get ("aliases",[]))
+ names_to_add_str_list .append (f"({aliases_str })~")
+ else :
+ names_to_add_str_list .append (entry .get ("name",""))
+
+ names_to_add_str_list =[s for s in names_to_add_str_list if s ]
+
+ if not names_to_add_str_list :
+ return
+
+ current_filter_text =self .character_input .text ().strip ()
+ new_text_to_append =", ".join (names_to_add_str_list )
+
+ self .character_input .setText (f"{current_filter_text }, {new_text_to_append }"if current_filter_text else new_text_to_append )
+ self .log_signal .emit (f"ℹ️ Added to character filter: {new_text_to_append }")
+
+ def _update_favorite_scope_button_text (self ):
+ if not hasattr (self ,'favorite_scope_toggle_button')or not self .favorite_scope_toggle_button :
+ return
+ if self .favorite_download_scope ==FAVORITE_SCOPE_SELECTED_LOCATION :
+ self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_selected_location_text","Scope: Selected Location"))
+
+ elif self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS :
+ self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_artist_folders_text","Scope: Artist Folders"))
+
+ else :
+ self .favorite_scope_toggle_button .setText (self ._tr ("favorite_scope_unknown_text","Scope: Unknown"))
+
+
+ def _cycle_favorite_scope (self ):
+ if self .favorite_download_scope ==FAVORITE_SCOPE_SELECTED_LOCATION :
+ self .favorite_download_scope =FAVORITE_SCOPE_ARTIST_FOLDERS
+ else :
+ self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
+ self ._update_favorite_scope_button_text ()
+ self .log_signal .emit (f"ℹ️ Favorite download scope changed to: '{self .favorite_download_scope }'")
+
+ def _show_empty_popup (self ):
+ """Creates and shows the empty popup dialog."""
+ if self.is_restore_pending:
+ QMessageBox.information(self, self._tr("restore_pending_title", "Restore Pending"),
+ self._tr("restore_pending_message_creator_selection",
+ "Please 'Restore Download' or 'Discard Session' before selecting new creators."))
+ return
+
+ dialog =EmptyPopupDialog (self .app_base_dir ,self ,self )
+ if dialog .exec_ ()==QDialog .Accepted :
+ if hasattr (dialog ,'selected_creators_for_queue')and dialog .selected_creators_for_queue :
+ self .favorite_download_queue .clear ()
+
+ for creator_data in dialog .selected_creators_for_queue :
+ service =creator_data .get ('service')
+ creator_id =creator_data .get ('id')
+ creator_name =creator_data .get ('name','Unknown Creator')
+ domain =dialog ._get_domain_for_service (service )
+
+ if service and creator_id :
+ url =f"https://{domain }/{service }/user/{creator_id }"
+ queue_item ={
+ 'url':url ,
+ 'name':creator_name ,
+ 'name_for_folder':creator_name ,
+ 'type':'creator_popup_selection',
+ 'scope_from_popup':dialog .current_scope_mode
+ }
+ self .favorite_download_queue .append (queue_item )
+
+ if self .favorite_download_queue :
+ self .log_signal .emit (f"ℹ️ {len (self .favorite_download_queue )} creators added to download queue from popup. Click 'Start Download' to process.")
+ if hasattr (self ,'link_input'):
+ self .last_link_input_text_for_queue_sync =self .link_input .text ()
+
+ def _show_favorite_artists_dialog (self ):
+ if self ._is_download_active ()or self .is_processing_favorites_queue :
+ QMessageBox .warning (self ,"Busy","Another download operation is already in progress.")
+ return
+
+ cookies_config ={
+ 'use_cookie':self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ,
+ 'cookie_text':self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "",
+ 'selected_cookie_file':self .selected_cookie_filepath ,
+ 'app_base_dir':self .app_base_dir
+ }
+
+ dialog =FavoriteArtistsDialog (self ,cookies_config )
+ if dialog .exec_ ()==QDialog .Accepted :
+ selected_artists =dialog .get_selected_artists ()
+ if selected_artists :
+ if len (selected_artists )>1 and self .link_input :
+ display_names =", ".join ([artist ['name']for artist in selected_artists ])
+ if self .link_input :
+ self .link_input .clear ()
+ self .link_input .setPlaceholderText (f"{len (selected_artists )} favorite artists selected for download queue.")
+ self .log_signal .emit (f"ℹ️ Multiple favorite artists selected. Displaying names: {display_names }")
+ elif len (selected_artists )==1 :
+ self .link_input .setText (selected_artists [0 ]['url'])
+ self .log_signal .emit (f"ℹ️ Single favorite artist selected: {selected_artists [0 ]['name']}")
+
+ self .log_signal .emit (f"ℹ️ Queuing {len (selected_artists )} favorite artist(s) for download.")
+ for artist_data in selected_artists :
+ self .favorite_download_queue .append ({'url':artist_data ['url'],'name':artist_data ['name'],'name_for_folder':artist_data ['name'],'type':'artist'})
+
+ if not self .is_processing_favorites_queue :
+ self ._process_next_favorite_download ()
+ else :
+ self .log_signal .emit ("ℹ️ No favorite artists were selected for download.")
+ QMessageBox .information (self ,
+ self ._tr ("fav_artists_no_selection_title","No Selection"),
+ self ._tr ("fav_artists_no_selection_message","Please select at least one artist to download."))
+ else :
+ self .log_signal .emit ("ℹ️ Favorite artists selection cancelled.")
+
+ def _show_favorite_posts_dialog (self ):
+ if self ._is_download_active ()or self .is_processing_favorites_queue :
+ QMessageBox .warning (self ,"Busy","Another download operation is already in progress.")
+ return
+
+ cookies_config ={
+ 'use_cookie':self .use_cookie_checkbox .isChecked ()if hasattr (self ,'use_cookie_checkbox')else False ,
+ 'cookie_text':self .cookie_text_input .text ()if hasattr (self ,'cookie_text_input')else "",
+ 'selected_cookie_file':self .selected_cookie_filepath ,
+ 'app_base_dir':self .app_base_dir
+ }
+ global KNOWN_NAMES
+
+ target_domain_preference_for_fetch =None
+
+ if cookies_config ['use_cookie']:
+ self .log_signal .emit ("Favorite Posts: 'Use Cookie' is checked. Determining target domain...")
+ kemono_cookies =prepare_cookies_for_request (
+ cookies_config ['use_cookie'],
+ cookies_config ['cookie_text'],
+ cookies_config ['selected_cookie_file'],
+ cookies_config ['app_base_dir'],
+ lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Kemono] {msg }"),
+ target_domain ="kemono.su"
+ )
+ coomer_cookies =prepare_cookies_for_request (
+ cookies_config ['use_cookie'],
+ cookies_config ['cookie_text'],
+ cookies_config ['selected_cookie_file'],
+ cookies_config ['app_base_dir'],
+ lambda msg :self .log_signal .emit (f"[FavPosts Cookie Check - Coomer] {msg }"),
+ target_domain ="coomer.su"
+ )
+
+ kemono_ok =bool (kemono_cookies )
+ coomer_ok =bool (coomer_cookies )
+
+ if kemono_ok and not coomer_ok :
+ target_domain_preference_for_fetch ="kemono.su"
+ self .log_signal .emit (" ↳ Only Kemono.su cookies loaded. Will fetch favorites from Kemono.su only.")
+ elif coomer_ok and not kemono_ok :
+ target_domain_preference_for_fetch ="coomer.su"
+ self .log_signal .emit (" ↳ Only Coomer.su cookies loaded. Will fetch favorites from Coomer.su only.")
+ elif kemono_ok and coomer_ok :
+ target_domain_preference_for_fetch =None
+ self .log_signal .emit (" ↳ Cookies for both Kemono.su and Coomer.su loaded. Will attempt to fetch from both.")
+ else :
+ self .log_signal .emit (" ↳ No valid cookies loaded for Kemono.su or Coomer.su.")
+ cookie_help_dialog =CookieHelpDialog (self ,self )
+ cookie_help_dialog .exec_ ()
+ return
+ else :
+ self .log_signal .emit ("Favorite Posts: 'Use Cookie' is NOT checked. Cookies are required.")
+ cookie_help_dialog =CookieHelpDialog (self ,self )
+ cookie_help_dialog .exec_ ()
+ return
+
+ dialog =FavoritePostsDialog (self ,cookies_config ,KNOWN_NAMES ,target_domain_preference_for_fetch )
+ if dialog .exec_ ()==QDialog .Accepted :
+ selected_posts =dialog .get_selected_posts ()
+ if selected_posts :
+ self .log_signal .emit (f"ℹ️ Queuing {len (selected_posts )} favorite post(s) for download.")
+ for post_data in selected_posts :
+ domain =self ._get_domain_for_service (post_data ['service'])
+ direct_post_url =f"https://{domain }/{post_data ['service']}/user/{str (post_data ['creator_id'])}/post/{str (post_data ['post_id'])}"
+
+ queue_item ={
+ 'url':direct_post_url ,
+ 'name':post_data ['title'],
+ 'name_for_folder':post_data ['creator_name_resolved'],
+ 'type':'post'
+ }
+ self .favorite_download_queue .append (queue_item )
+
+ if not self .is_processing_favorites_queue :
+ self ._process_next_favorite_download ()
+ else :
+ self .log_signal .emit ("ℹ️ No favorite posts were selected for download.")
+ else :
+ self .log_signal .emit ("ℹ️ Favorite posts selection cancelled.")
+
+ def _process_next_favorite_download (self ):
+ if self ._is_download_active ():
+ self .log_signal .emit ("ℹ️ Waiting for current download to finish before starting next favorite.")
+ return
+ if not self .favorite_download_queue :
+ if self .is_processing_favorites_queue :
+ self .is_processing_favorites_queue =False
+ item_type_log ="item"
+ if hasattr (self ,'current_processing_favorite_item_info')and self .current_processing_favorite_item_info :
+ item_type_log =self .current_processing_favorite_item_info .get ('type','item')
+ self .log_signal .emit (f"✅ All {item_type_log } downloads from favorite queue have been processed.")
+ self .set_ui_enabled (True )
+ return
+ if not self .is_processing_favorites_queue :
+ self .is_processing_favorites_queue =True
+ self .current_processing_favorite_item_info =self .favorite_download_queue .popleft ()
+ next_url =self .current_processing_favorite_item_info ['url']
+ item_display_name =self .current_processing_favorite_item_info .get ('name','Unknown Item')
+
+ item_type =self .current_processing_favorite_item_info .get ('type','artist')
+ self .log_signal .emit (f"▶️ Processing next favorite from queue: '{item_display_name }' ({next_url })")
+
+ override_dir =None
+ item_scope =self .current_processing_favorite_item_info .get ('scope_from_popup')
+ if item_scope is None :
+ item_scope =self .favorite_download_scope
+
+ main_download_dir =self .dir_input .text ().strip ()
+
+
+
+
+ should_create_artist_folder =False
+ if item_type =='creator_popup_selection'and item_scope ==EmptyPopupDialog .SCOPE_CREATORS :
+ should_create_artist_folder =True
+ elif item_type !='creator_popup_selection'and self .favorite_download_scope ==FAVORITE_SCOPE_ARTIST_FOLDERS :
+ should_create_artist_folder =True
+
+ if should_create_artist_folder and main_download_dir :
+ folder_name_key =self .current_processing_favorite_item_info .get ('name_for_folder','Unknown_Folder')
+ item_specific_folder_name =clean_folder_name (folder_name_key )
+ override_dir =os .path .normpath (os .path .join (main_download_dir ,item_specific_folder_name ))
+ self .log_signal .emit (f" Scope requires artist folder. Target directory: '{override_dir }'")
+
+ success_starting_download =self .start_download (direct_api_url =next_url ,override_output_dir =override_dir )
+
+ if not success_starting_download :
+ self .log_signal .emit (f"⚠️ Failed to initiate download for '{item_display_name }'. Skipping this item in queue.")
+ self .download_finished (total_downloaded =0 ,total_skipped =1 ,cancelled_by_user =True ,kept_original_names_list =[])
+
+class ExternalLinkDownloadThread (QThread ):
+ """A QThread to handle downloading multiple external links sequentially."""
+ progress_signal =pyqtSignal (str )
+ file_complete_signal =pyqtSignal (str ,bool )
+ finished_signal =pyqtSignal ()
+
+ def __init__ (self ,tasks_to_download ,download_base_path ,parent_logger_func ,parent =None ):
+ super ().__init__ (parent )
+ self .tasks =tasks_to_download
+ self .download_base_path =download_base_path
+ self .parent_logger_func =parent_logger_func
+ self .is_cancelled =False
+
+ def run (self ):
+ self .progress_signal .emit (f"ℹ️ Starting external link download thread for {len (self .tasks )} link(s).")
+ for i ,task_info in enumerate (self .tasks ):
+ if self .is_cancelled :
+ self .progress_signal .emit ("External link download cancelled by user.")
+ break
+
+ platform =task_info .get ('platform','unknown').lower ()
+ full_mega_url =task_info ['url']
+ post_title =task_info ['title']
+ key =task_info .get ('key','')
+
+ self .progress_signal .emit (f"Download ({i +1 }/{len (self .tasks )}): Starting '{post_title }' ({platform .upper ()}) from {full_mega_url }")
+
+ try :
+ if platform =='mega':
+
+ if key :
+ parsed_original_url =urlparse (full_mega_url )
+ if key not in parsed_original_url .fragment :
+ base_url_no_fragment =full_mega_url .split ('#')[0 ]
+ full_mega_url_with_key =f"{base_url_no_fragment }#{key }"
+ self .progress_signal .emit (f" Adjusted Mega URL with key: {full_mega_url_with_key }")
+ else :
+ full_mega_url_with_key =full_mega_url
+ else :
+ full_mega_url_with_key =full_mega_url
+ drive_download_mega_file (full_mega_url_with_key ,self .download_base_path ,logger_func =self .parent_logger_func )
+ elif platform =='google drive':
+ download_gdrive_file (full_mega_url ,self .download_base_path ,logger_func =self .parent_logger_func )
+ elif platform =='dropbox':
+ download_dropbox_file (full_mega_url ,self .download_base_path ,logger_func =self .parent_logger_func )
+ else :
+ self .progress_signal .emit (f"⚠️ Unsupported platform '{platform }' for link: {full_mega_url }")
+ self .file_complete_signal .emit (full_mega_url ,False )
+ continue
+ self .file_complete_signal .emit (full_mega_url ,True )
+ except Exception as e :
+ self .progress_signal .emit (f"❌ Error downloading ({platform .upper ()}) link '{full_mega_url }' (from post '{post_title }'): {e }")
+ self .file_complete_signal .emit (full_mega_url ,False )
+ self .finished_signal .emit ()
+
+ def cancel (self ):
+ self .is_cancelled =True
\ No newline at end of file
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
new file mode 100644
index 0000000..7c4386b
--- /dev/null
+++ b/src/utils/__init__.py
@@ -0,0 +1 @@
+# ...existing code...
\ No newline at end of file
diff --git a/src/utils/file_utils.py b/src/utils/file_utils.py
new file mode 100644
index 0000000..de0034c
--- /dev/null
+++ b/src/utils/file_utils.py
@@ -0,0 +1,142 @@
+# --- Standard Library Imports ---
+import os
+import re
+
+# --- Module Constants ---
+
+# This will be populated at runtime by the main application,
+# but is defined here as it's conceptually related to file/folder naming.
+KNOWN_NAMES = []
+
+MAX_FILENAME_COMPONENT_LENGTH = 150
+
+# Sets of file extensions for quick type checking
+IMAGE_EXTENSIONS = {
+ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
+ '.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
+}
+VIDEO_EXTENSIONS = {
+ '.mp4', '.mov', '.mkv', '.webm', '.avi', '.wmv', '.flv', '.mpeg',
+ '.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob'
+}
+ARCHIVE_EXTENSIONS = {
+ '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'
+}
+AUDIO_EXTENSIONS = {
+ '.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
+ '.aiff', '.ape', '.mid', '.midi'
+}
+
+# Words to ignore when trying to generate a folder name from a title
+FOLDER_NAME_STOP_WORDS = {
+ "a", "alone", "am", "an", "and", "at", "be", "blues", "but", "by", "com",
+ "for", "grown", "hard", "he", "her", "his", "hitting", "i", "im", "in", "is", "it", "its",
+ "me", "much", "my", "net", "not", "of", "on", "or", "org", "our", "please",
+ "right", "s", "she", "so", "technically", "tell", "the", "their", "they", "this",
+ "to", "ve", "was", "we", "well", "were", "with", "www", "year", "you", "your",
+}
+
+# --- File and Folder Name Utilities ---
+
+def clean_folder_name(name):
+ """
+ Sanitizes a string to make it a valid folder name.
+ Removes invalid characters and trims whitespace.
+
+ Args:
+ name (str): The input string.
+
+ Returns:
+ str: A sanitized, valid folder name.
+ """
+ if not isinstance(name, str):
+ name = str(name)
+
+ # Remove characters that are invalid in folder names on most OS
+ cleaned = re.sub(r'[<>:"/\\|?*]', '', name)
+ cleaned = cleaned.strip()
+
+ # Replace multiple spaces with a single space
+ cleaned = re.sub(r'\s+', ' ', cleaned)
+
+ # If after cleaning the name is empty, provide a default
+ if not cleaned:
+ return "untitled_folder"
+
+ # Truncate to a reasonable length
+ if len(cleaned) > MAX_FILENAME_COMPONENT_LENGTH:
+ cleaned = cleaned[:MAX_FILENAME_COMPONENT_LENGTH]
+
+ # Remove trailing dots or spaces, which can be problematic
+ cleaned = cleaned.rstrip('. ')
+
+ return cleaned if cleaned else "untitled_folder"
+
+
+def clean_filename(name):
+ """
+ Sanitizes a string to make it a valid file name.
+
+ Args:
+ name (str): The input string.
+
+ Returns:
+ str: A sanitized, valid file name.
+ """
+ if not isinstance(name, str):
+ name = str(name)
+
+ cleaned = re.sub(r'[<>:"/\\|?*]', '_', name)
+ cleaned = cleaned.strip()
+
+ if not cleaned:
+ return "untitled_file"
+
+ base_name, ext = os.path.splitext(cleaned)
+ max_base_len = MAX_FILENAME_COMPONENT_LENGTH - len(ext)
+
+ if len(base_name) > max_base_len:
+ if max_base_len > 0:
+ base_name = base_name[:max_base_len]
+ else:
+ # Handle cases where the extension itself is too long
+ return cleaned[:MAX_FILENAME_COMPONENT_LENGTH]
+
+ return base_name + ext
+
+
+# --- File Type Identification Functions ---
+
+def is_image(filename):
+ """Checks if a filename has a common image extension."""
+ if not filename: return False
+ _, ext = os.path.splitext(filename)
+ return ext.lower() in IMAGE_EXTENSIONS
+
+def is_video(filename):
+ """Checks if a filename has a common video extension."""
+ if not filename: return False
+ _, ext = os.path.splitext(filename)
+ return ext.lower() in VIDEO_EXTENSIONS
+
+def is_zip(filename):
+ """Checks if a filename is a .zip file."""
+ if not filename: return False
+ return filename.lower().endswith('.zip')
+
+def is_rar(filename):
+ """Checks if a filename is a .rar file."""
+ if not filename: return False
+ return filename.lower().endswith('.rar')
+
+def is_archive(filename):
+ """Checks if a filename has a common archive extension."""
+ if not filename: return False
+ _, ext = os.path.splitext(filename)
+ return ext.lower() in ARCHIVE_EXTENSIONS
+
+def is_audio(filename):
+ """Checks if a filename has a common audio extension."""
+ if not filename: return False
+ _, ext = os.path.splitext(filename)
+ return ext.lower() in AUDIO_EXTENSIONS
diff --git a/src/utils/network_utils.py b/src/utils/network_utils.py
new file mode 100644
index 0000000..fb5e882
--- /dev/null
+++ b/src/utils/network_utils.py
@@ -0,0 +1,208 @@
+# --- Standard Library Imports ---
+import os
+import re
+from urllib.parse import urlparse
+
+# --- Third-Party Library Imports ---
+# This module might not require third-party libraries directly,
+# but 'requests' is a common dependency for network operations.
+# import requests
+
+
+def parse_cookie_string(cookie_string):
+ """
+ Parses a 'name=value; name2=value2' cookie string into a dictionary.
+
+ Args:
+ cookie_string (str): The cookie string from browser tools.
+
+ Returns:
+ dict or None: A dictionary of cookie names and values, or None if empty.
+ """
+ cookies = {}
+ if cookie_string:
+ for item in cookie_string.split(';'):
+ parts = item.split('=', 1)
+ if len(parts) == 2:
+ name = parts[0].strip()
+ value = parts[1].strip()
+ if name:
+ cookies[name] = value
+ return cookies if cookies else None
+
+
+def load_cookies_from_netscape_file(filepath, logger_func, target_domain_filter=None):
+ """
+ Loads cookies from a Netscape-formatted cookies.txt file.
+
+ If a target_domain_filter is provided, only cookies for that domain
+ (or its subdomains) are returned.
+
+ Args:
+ filepath (str): The full path to the cookies.txt file.
+ logger_func (callable): Function to use for logging.
+ target_domain_filter (str, optional): The domain to filter cookies for.
+
+ Returns:
+ dict or None: A dictionary of cookie names and values, or None if none are loaded.
+ """
+ cookies = {}
+ try:
+ with open(filepath, 'r', encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ parts = line.split('\t')
+ if len(parts) == 7:
+ cookie_domain = parts[0]
+ name = parts[5]
+ value = parts[6]
+
+ if not name:
+ continue
+
+ if target_domain_filter:
+ # Match domain exactly or as a subdomain
+ host_to_match = target_domain_filter.lower()
+ cookie_domain_norm = cookie_domain.lower()
+ if (cookie_domain_norm.startswith('.') and host_to_match.endswith(cookie_domain_norm)) or \
+ (host_to_match == cookie_domain_norm):
+ cookies[name] = value
+ else:
+ cookies[name] = value
+
+ logger_func(f" 🍪 Loaded {len(cookies)} cookies from '{os.path.basename(filepath)}' for domain '{target_domain_filter or 'any'}'.")
+ return cookies if cookies else None
+ except FileNotFoundError:
+ logger_func(f" 🍪 Cookie file '{os.path.basename(filepath)}' not found.")
+ return None
+ except Exception as e:
+ logger_func(f" 🍪 Error parsing cookie file '{os.path.basename(filepath)}': {e}")
+ return None
+
+
+def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_cookie_file_path, app_base_dir, logger_func, target_domain=None):
+ """
+ Prepares a cookie dictionary from various sources based on user settings.
+ Priority:
+ 1. UI-selected file path.
+ 2. Domain-specific file in the app directory.
+ 3. Default `cookies.txt` in the app directory.
+ 4. Manually entered cookie text.
+
+ Args:
+ use_cookie_flag (bool): Whether cookies are enabled in the UI.
+ cookie_text_input (str): The raw text from the cookie input field.
+ selected_cookie_file_path (str): The path to a user-browsed cookie file.
+ app_base_dir (str): The base directory of the application.
+ logger_func (callable): Function for logging.
+ target_domain (str, optional): The domain for which cookies are needed.
+
+ Returns:
+ dict or None: A dictionary of cookies for the request, or None.
+ """
+ if not use_cookie_flag:
+ return None
+
+ # Priority 1: Use the specifically browsed file first
+ if selected_cookie_file_path and os.path.exists(selected_cookie_file_path):
+ cookies = load_cookies_from_netscape_file(selected_cookie_file_path, logger_func, target_domain)
+ if cookies:
+ return cookies
+
+ # Priority 2: Look for a domain-specific cookie file
+ if app_base_dir and target_domain:
+ domain_specific_path = os.path.join(app_base_dir, "data", f"{target_domain}_cookies.txt")
+ if os.path.exists(domain_specific_path):
+ cookies = load_cookies_from_netscape_file(domain_specific_path, logger_func, target_domain)
+ if cookies:
+ return cookies
+
+ # Priority 3: Look for a generic cookies.txt
+ if app_base_dir:
+ default_path = os.path.join(app_base_dir, "appdata", "cookies.txt")
+ if os.path.exists(default_path):
+ cookies = load_cookies_from_netscape_file(default_path, logger_func, target_domain)
+ if cookies:
+ return cookies
+
+ # Priority 4: Fall back to manually entered text
+ if cookie_text_input:
+ cookies = parse_cookie_string(cookie_text_input)
+ if cookies:
+ return cookies
+
+ logger_func(f" 🍪 Cookie usage enabled for '{target_domain or 'any'}', but no valid cookies found.")
+ return None
+
+
+def extract_post_info(url_string):
+ """
+ Parses a URL string to extract the service, user ID, and post ID.
+
+ Args:
+ url_string (str): The URL to parse.
+
+ Returns:
+ tuple: A tuple containing (service, user_id, post_id). Any can be None.
+ """
+ if not isinstance(url_string, str) or not url_string.strip():
+ return None, None, None
+
+ try:
+ parsed_url = urlparse(url_string.strip())
+ path_parts = [part for part in parsed_url.path.strip('/').split('/') if part]
+
+ # Standard format: //user//post/
+ if len(path_parts) >= 3 and path_parts[1].lower() == 'user':
+ service = path_parts[0]
+ user_id = path_parts[2]
+ post_id = path_parts[4] if len(path_parts) >= 5 and path_parts[3].lower() == 'post' else None
+ return service, user_id, post_id
+
+ # API format: /api/v1//user/...
+ if len(path_parts) >= 5 and path_parts[0:2] == ['api', 'v1'] and path_parts[3].lower() == 'user':
+ service = path_parts[2]
+ user_id = path_parts[4]
+ post_id = path_parts[6] if len(path_parts) >= 7 and path_parts[5].lower() == 'post' else None
+ return service, user_id, post_id
+
+ except Exception as e:
+ print(f"Debug: Exception during URL parsing for '{url_string}': {e}")
+
+ return None, None, None
+
+
+def get_link_platform(url):
+ """
+ Identifies the platform of a given URL based on its domain.
+
+ Args:
+ url (str): The URL to identify.
+
+ Returns:
+ str: The name of the platform (e.g., 'mega', 'google drive') or 'external'.
+ """
+ try:
+ domain = urlparse(url).netloc.lower()
+ if 'drive.google.com' in domain: return 'google drive'
+ if 'mega.nz' in domain or 'mega.io' in domain: return 'mega'
+ if 'dropbox.com' in domain: return 'dropbox'
+ if 'patreon.com' in domain: return 'patreon'
+ if 'gofile.io' in domain: return 'gofile'
+ if 'instagram.com' in domain: return 'instagram'
+ if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x'
+ if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite'
+ if 'pixiv.net' in domain: return 'pixiv'
+ if 'kemono.su' in domain or 'kemono.party' in domain: return 'kemono'
+ if 'coomer.su' in domain or 'coomer.party' in domain: return 'coomer'
+
+ # Fallback to a generic name for other domains
+ parts = domain.split('.')
+ if len(parts) >= 2:
+ return parts[-2]
+ return 'external'
+ except Exception:
+ return 'unknown'
diff --git a/src/utils/text_utils.py b/src/utils/text_utils.py
new file mode 100644
index 0000000..ad9e45e
--- /dev/null
+++ b/src/utils/text_utils.py
@@ -0,0 +1,207 @@
+# --- Standard Library Imports ---
+import re
+import html
+
+# --- Local Application Imports ---
+# Import from file_utils within the same package
+from .file_utils import clean_folder_name, FOLDER_NAME_STOP_WORDS
+
+# --- Module Constants ---
+
+# Regular expression patterns for cleaning up titles before matching against Known.txt
+KNOWN_TXT_MATCH_CLEANUP_PATTERNS = [
+ r'\bcum\b',
+ r'\bnsfw\b',
+ r'\bsfw\b',
+ r'\bweb\b',
+ r'\bhd\b',
+ r'\bhi\s*res\b',
+ r'\bhigh\s*res\b',
+ r'\b\d+p\b',
+ r'\b\d+k\b',
+ r'\[OC\]',
+ r'\[Request(?:s)?\]',
+ r'\bCommission\b',
+ r'\bComm\b',
+ r'\bPreview\b',
+]
+
+# --- Text Matching and Manipulation Utilities ---
+
+def is_title_match_for_character(post_title, character_name_filter):
+ """
+ Checks if a post title contains a specific character name as a whole word.
+ Case-insensitive.
+
+ Args:
+ post_title (str): The title of the post.
+ character_name_filter (str): The character name to search for.
+
+ Returns:
+ bool: True if the name is found as a whole word, False otherwise.
+ """
+ if not post_title or not character_name_filter:
+ return False
+
+ # Use word boundaries (\b) to match whole words only
+ pattern = r"(?i)\b" + re.escape(str(character_name_filter).strip()) + r"\b"
+ return bool(re.search(pattern, post_title))
+
+
+def is_filename_match_for_character(filename, character_name_filter):
+ """
+ Checks if a filename contains a character name. This is a simple substring check.
+ Case-insensitive.
+
+ Args:
+ filename (str): The name of the file.
+ character_name_filter (str): The character name to search for.
+
+ Returns:
+ bool: True if the substring is found, False otherwise.
+ """
+ if not filename or not character_name_filter:
+ return False
+
+ return str(character_name_filter).strip().lower() in filename.lower()
+
+
+def strip_html_tags(html_text):
+ """
+ Removes HTML tags from a string and cleans up resulting whitespace.
+
+ Args:
+ html_text (str): The input string containing HTML.
+
+ Returns:
+ str: The text with HTML tags removed.
+ """
+ if not html_text:
+ return ""
+ # First, unescape HTML entities like & -> &
+ text = html.unescape(str(html_text))
+ # Remove all tags
+ text_after_tag_removal = re.sub(r'<[^>]+>', ' ', text)
+ # Replace multiple whitespace characters with a single space
+ cleaned_text = re.sub(r'\s+', ' ', text_after_tag_removal).strip()
+ return cleaned_text
+
+
+def extract_folder_name_from_title(title, unwanted_keywords):
+ """
+ Extracts a plausible folder name from a post title by finding the first
+ significant word that isn't a stop-word.
+
+ Args:
+ title (str): The post title.
+ unwanted_keywords (set): A set of words to ignore.
+
+ Returns:
+ str: The extracted folder name, or 'Uncategorized'.
+ """
+ if not title:
+ return 'Uncategorized'
+
+ title_lower = title.lower()
+ # Find all whole words in the title
+ tokens = re.findall(r'\b[\w\-]+\b', title_lower)
+
+ for token in tokens:
+ clean_token = clean_folder_name(token)
+ if clean_token and clean_token.lower() not in unwanted_keywords:
+ return clean_token
+
+ # Fallback to cleaning the full title if no single significant word is found
+ cleaned_full_title = clean_folder_name(title)
+ return cleaned_full_title if cleaned_full_title else 'Uncategorized'
+
+
+def match_folders_from_title(title, names_to_match, unwanted_keywords):
+ """
+ Matches folder names from a title based on a list of known name objects.
+ Each name object is a dict: {'name': 'PrimaryName', 'aliases': ['alias1', ...]}
+
+ Args:
+ title (str): The post title to check.
+ names_to_match (list): A list of known name dictionaries.
+ unwanted_keywords (set): A set of folder names to ignore.
+
+ Returns:
+ list: A sorted list of matched primary folder names.
+ """
+ if not title or not names_to_match:
+ return []
+
+ # Clean the title by removing common tags like [OC], [HD], etc.
+ cleaned_title = title
+ for pat_str in KNOWN_TXT_MATCH_CLEANUP_PATTERNS:
+ cleaned_title = re.sub(pat_str, ' ', cleaned_title, flags=re.IGNORECASE)
+ cleaned_title = re.sub(r'\s+', ' ', cleaned_title).strip()
+ title_lower = cleaned_title.lower()
+
+ matched_cleaned_names = set()
+
+ # Sort by name length descending to match longer names first (e.g., "Cloud Strife" before "Cloud")
+ sorted_name_objects = sorted(names_to_match, key=lambda x: len(x.get("name", "")), reverse=True)
+
+ for name_obj in sorted_name_objects:
+ primary_folder_name = name_obj.get("name")
+ aliases = name_obj.get("aliases", [])
+ if not primary_folder_name or not aliases:
+ continue
+
+ for alias in aliases:
+ alias_lower = alias.lower()
+ if not alias_lower: continue
+
+ # Use word boundaries for accurate matching
+ pattern = r'\b' + re.escape(alias_lower) + r'\b'
+ if re.search(pattern, title_lower):
+ cleaned_primary_name = clean_folder_name(primary_folder_name)
+ if cleaned_primary_name.lower() not in unwanted_keywords:
+ matched_cleaned_names.add(cleaned_primary_name)
+ break # Move to the next name object once a match is found for this one
+
+ return sorted(list(matched_cleaned_names))
+
+
+def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keywords):
+ """
+ Matches folder names from a filename, prioritizing longer and more specific aliases.
+
+ Args:
+ filename (str): The filename to check.
+ names_to_match (list): A list of known name dictionaries.
+ unwanted_keywords (set): A set of folder names to ignore.
+
+ Returns:
+ list: A sorted list of matched primary folder names.
+ """
+ if not filename or not names_to_match:
+ return []
+
+ filename_lower = filename.lower()
+ matched_primary_names = set()
+
+ # Create a flat list of (alias, primary_name) tuples to sort by alias length
+ alias_map_to_primary = []
+ for name_obj in names_to_match:
+ primary_name = name_obj.get("name")
+ if not primary_name: continue
+
+ cleaned_primary_name = clean_folder_name(primary_name)
+ if not cleaned_primary_name or cleaned_primary_name.lower() in unwanted_keywords:
+ continue
+
+ for alias in name_obj.get("aliases", []):
+ if alias.lower():
+ alias_map_to_primary.append((alias.lower(), cleaned_primary_name))
+
+ # Sort by alias length, descending, to match longer aliases first
+ alias_map_to_primary.sort(key=lambda x: len(x[0]), reverse=True)
+
+ for alias_lower, primary_name_for_alias in alias_map_to_primary:
+ if filename_lower.startswith(alias_lower):
+ matched_primary_names.add(primary_name_for_alias)
+
+ return sorted(list(matched_primary_names))