1 Commits

Author SHA1 Message Date
Yuvi9587
191dbc8c62 Commit 2025-06-19 08:25:30 +01:00
4 changed files with 161 additions and 42 deletions

24
LICENSE
View File

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

View File

@@ -1689,7 +1689,48 @@ class PostProcessorWorker :
if not self .extract_links_only and self .use_post_subfolders :
cleaned_post_title_for_sub =clean_folder_name (post_title )
determined_post_save_path_for_history =os .path .join (determined_post_save_path_for_history ,cleaned_post_title_for_sub )
post_id_for_fallback = self.post.get('id', 'unknown_id') # Ensure post_id is available
# Fallback to a more unique name if the cleaned title is generic
if not cleaned_post_title_for_sub or cleaned_post_title_for_sub == "untitled_folder":
self.logger(f" ⚠️ Post title '{post_title}' resulted in a generic subfolder name. Using 'post_{post_id_for_fallback}' as base.")
original_cleaned_post_title_for_sub = f"post_{post_id_for_fallback}"
else:
original_cleaned_post_title_for_sub = cleaned_post_title_for_sub
# Path before adding the post-specific subfolder
base_path_for_post_subfolder = determined_post_save_path_for_history
suffix_counter = 0 # 0 for no suffix, 1 for _1, etc.
final_post_subfolder_name = ""
while True:
if suffix_counter == 0:
name_candidate = original_cleaned_post_title_for_sub
else:
name_candidate = f"{original_cleaned_post_title_for_sub}_{suffix_counter}"
potential_post_subfolder_path = os.path.join(base_path_for_post_subfolder, name_candidate)
try:
os.makedirs(potential_post_subfolder_path, exist_ok=False)
final_post_subfolder_name = name_candidate
if suffix_counter > 0: # Log only if a suffix was actually needed and used
self.logger(f" Post subfolder name conflict: Using '{final_post_subfolder_name}' instead of '{original_cleaned_post_title_for_sub}' to avoid mixing posts.")
break
except FileExistsError:
suffix_counter += 1
if suffix_counter > 100: # Safety break
self.logger(f" ⚠️ Exceeded 100 attempts to find unique subfolder name for '{original_cleaned_post_title_for_sub}'. Using UUID.")
final_post_subfolder_name = f"{original_cleaned_post_title_for_sub}_{uuid.uuid4().hex[:8]}"
os.makedirs(os.path.join(base_path_for_post_subfolder, final_post_subfolder_name), exist_ok=True) # Create with exist_ok=True as a last resort
break
except OSError as e_mkdir:
self.logger(f" ❌ Error creating directory '{potential_post_subfolder_path}': {e_mkdir}. Files for this post might be saved in parent or fail.")
final_post_subfolder_name = original_cleaned_post_title_for_sub # Fallback
break
determined_post_save_path_for_history = os.path.join(base_path_for_post_subfolder, final_post_subfolder_name)
if not self .extract_links_only and self .use_subfolders and self .skip_words_list :
if self ._check_pause (f"Folder keyword skip check for post {post_id }"):return 0 ,num_potential_files_in_post ,[],[],[],None
@@ -1952,8 +1993,8 @@ class PostProcessorWorker :
if self .use_subfolders and target_base_folder_name_for_instance :
current_path_for_file_instance =os .path .join (current_path_for_file_instance ,target_base_folder_name_for_instance )
if self .use_post_subfolders :
cleaned_title_for_subfolder_instance =clean_folder_name (post_title )
current_path_for_file_instance =os .path .join (current_path_for_file_instance ,cleaned_title_for_subfolder_instance )
# Use the final_post_subfolder_name determined earlier, which includes suffix if needed
current_path_for_file_instance =os .path .join (current_path_for_file_instance ,final_post_subfolder_name )
manga_date_counter_to_pass =self .manga_date_file_counter_ref if self .manga_mode_active and self .manga_filename_style ==STYLE_DATE_BASED else None
manga_global_counter_to_pass =self .manga_global_file_counter_ref if self .manga_mode_active and self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING else None
@@ -2021,6 +2062,22 @@ 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 }")
# Cleanup: Remove empty post-specific subfolder if created and no files were downloaded
if not self.extract_links_only and self.use_post_subfolders and total_downloaded_this_post == 0:
# determined_post_save_path_for_history at this point holds the full path to the post-specific subfolder
# if self.use_post_subfolders was true and it was applied.
# base_path_for_post_subfolder was the path *before* the post-specific segment.
# final_post_subfolder_name was the segment itself.
# So, determined_post_save_path_for_history is the correct path to check.
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):
self.logger(f" 🗑️ Removing empty post-specific subfolder: '{path_to_check_for_emptiness}'")
os.rmdir(path_to_check_for_emptiness)
except OSError as e_rmdir:
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 )

View File

@@ -296,6 +296,22 @@ Output: '2023-01-15_ChapterOne.jpg', '2023-01-15_ChapterOne_1.png'""",
"download_external_links_dialog_title":"Download Selected External Links",
"select_all_button_text":"Select All",
"deselect_all_button_text":"Deselect All",
"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:",

100
main.py
View File

@@ -178,6 +178,7 @@ 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"
@@ -719,6 +720,11 @@ class FutureSettingsDialog (QDialog ):
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 ()
@@ -740,6 +746,9 @@ class FutureSettingsDialog (QDialog ):
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":
@@ -812,6 +821,29 @@ class FutureSettingsDialog (QDialog ):
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"
@@ -1145,8 +1177,6 @@ class EmptyPopupDialog (QDialog ):
self .all_creators_data =[]
self .progress_bar .setVisible (False );QCoreApplication .processEvents ();return
self ._filter_list ()
def _populate_list_widget (self ,creators_to_display ):
@@ -1219,16 +1249,8 @@ class EmptyPopupDialog (QDialog ):
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 :
@@ -2116,8 +2138,24 @@ class KnownNamesFilterDialog (QDialog ):
self ._retranslate_ui ()
self ._populate_list_widget ()
self .setMinimumWidth (350 )
self .setMinimumHeight (400 )
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 )
@@ -2137,6 +2175,12 @@ class KnownNamesFilterDialog (QDialog ):
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 :
@@ -2145,16 +2189,15 @@ class KnownNamesFilterDialog (QDialog ):
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 ()
if not search_text :
self ._populate_list_widget ()
return
filtered_entries =[
entry_obj for entry_obj in self .all_known_name_entries if search_text in entry_obj ['name'].lower ()
]
self ._populate_list_widget (filtered_entries )
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 =[]
@@ -3747,6 +3790,7 @@ class DownloaderApp (QWidget ):
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 ()
def _tr (self ,key ,default_text =""):
@@ -3755,6 +3799,18 @@ class DownloaderApp (QWidget ):
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 _initialize_persistent_history_path (self ):
documents_path =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation )
if not documents_path :
@@ -5990,7 +6046,7 @@ class DownloaderApp (QWidget ):
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 and not is_only_archives
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 )
@@ -7363,8 +7419,8 @@ class DownloaderApp (QWidget ):
downloaded_files_from_future ,skipped_files_from_future ,kept_originals_from_future ,retryable_failures_from_post ,permanent_failures_from_post ,history_data_from_worker =result_tuple
if history_data_from_worker :
self ._add_to_history_candidates (history_data_from_worker )
if permanent_failures_from_post:
self.permanently_failed_files_for_dialog.extend(permanent_failures_from_post)
if permanent_failures_from_post :
self .permanently_failed_files_for_dialog .extend (permanent_failures_from_post )
self ._add_to_history_candidates (history_data_from_worker )
with self .downloaded_files_lock :
self .download_counter +=downloaded_files_from_future