mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
1 Commits
783dfb985c
...
v5.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
384edfee3f |
24
LICENSE
24
LICENSE
@@ -1,11 +1,21 @@
|
||||
Custom License - No Commercial Use
|
||||
MIT License
|
||||
|
||||
Copyright [Yuvi9587] [2025]
|
||||
Copyright (c) [2025] [Yuvi9587]
|
||||
|
||||
Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for **non-commercial purposes only**, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
2. Proper credit must be given to the original author in any public use, distribution, or derivative works.
|
||||
3. Commercial use, resale, or sublicensing of the Software or any derivative works is strictly prohibited without explicit written permission.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND...
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
import requests
|
||||
import re
|
||||
import threading
|
||||
import json
|
||||
import queue
|
||||
import hashlib
|
||||
import http .client
|
||||
@@ -457,56 +456,27 @@ def fetch_posts_paginated (api_url_base ,headers ,offset ,logger ,cancellation_e
|
||||
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 <max_retries :
|
||||
delay =retry_delay *(2 **attempt )
|
||||
logger (f" Retrying in {delay } seconds...")
|
||||
sleep_start =time .time ()
|
||||
while time .time ()-sleep_start <delay :
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
raise RuntimeError ("Fetch operation cancelled by user during retry delay.")
|
||||
time .sleep (0.1 )
|
||||
continue
|
||||
else :
|
||||
logger (f" ❌ Failed to fetch page after {max_retries +1 } attempts.")
|
||||
raise RuntimeError (f"Timeout or connection error fetching offset {offset } from {paginated_url }")
|
||||
|
||||
except requests .exceptions .RequestException as e :
|
||||
err_msg =f"Error fetching offset {offset } from {paginated_url }: {e }"
|
||||
if e .response is not None :
|
||||
err_msg +=f" (Status: {e .response .status_code }, Body: {e .response .text [:200 ]})"
|
||||
if isinstance (e ,requests .exceptions .ConnectionError )and ("Failed to resolve"in str (e )or "NameResolutionError"in str (e )):
|
||||
err_msg +="\n 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN."
|
||||
raise RuntimeError (err_msg )
|
||||
except ValueError as e :
|
||||
raise RuntimeError (f"Error decoding JSON from offset {offset } ({paginated_url }): {e }. Response text: {response .text [:200 ]}")
|
||||
except Exception as e :
|
||||
raise RuntimeError (f"Unexpected error fetching offset {offset } ({paginated_url }): {e }")
|
||||
|
||||
raise RuntimeError (f"Failed to fetch page {paginated_url } after all attempts.")
|
||||
logger (f" Fetching: {paginated_url } (Page approx. {offset //50 +1 })")
|
||||
try :
|
||||
response =requests .get (paginated_url ,headers =headers ,timeout =(10 ,60 ),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 :
|
||||
raise RuntimeError (f"Timeout fetching offset {offset } from {paginated_url }")
|
||||
except requests .exceptions .RequestException as e :
|
||||
err_msg =f"Error fetching offset {offset } from {paginated_url }: {e }"
|
||||
if e .response is not None :
|
||||
err_msg +=f" (Status: {e .response .status_code }, Body: {e .response .text [:200 ]})"
|
||||
if isinstance (e ,requests .exceptions .ConnectionError )and ("Failed to resolve"in str (e )or "NameResolutionError"in str (e )):
|
||||
err_msg +="\n 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN."
|
||||
raise RuntimeError (err_msg )
|
||||
except ValueError as e :
|
||||
raise RuntimeError (f"Error decoding JSON from offset {offset } ({paginated_url }): {e }. Response text: {response .text [:200 ]}")
|
||||
except Exception as e :
|
||||
raise RuntimeError (f"Unexpected error fetching offset {offset } ({paginated_url }): {e }")
|
||||
def fetch_post_comments (api_domain ,service ,user_id ,post_id ,headers ,logger ,cancellation_event =None ,pause_event =None ,cookies_dict =None ):
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
logger (" Comment fetch cancelled before request.")
|
||||
@@ -520,56 +490,27 @@ def fetch_post_comments (api_domain ,service ,user_id ,post_id ,headers ,logger
|
||||
time .sleep (0.5 )
|
||||
logger (" Comment fetching resumed.")
|
||||
comments_api_url =f"https://{api_domain }/api/v1/{service }/user/{user_id }/post/{post_id }/comments"
|
||||
max_retries =2
|
||||
retry_delay =3
|
||||
|
||||
for attempt in range (max_retries +1 ):
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
raise RuntimeError ("Comment fetch operation cancelled by user during retry loop.")
|
||||
|
||||
log_message =f" Fetching comments: {comments_api_url }"
|
||||
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 <max_retries :
|
||||
delay =retry_delay *(2 **attempt )
|
||||
logger (f" Retrying in {delay } seconds...")
|
||||
sleep_start =time .time ()
|
||||
while time .time ()-sleep_start <delay :
|
||||
if cancellation_event and cancellation_event .is_set ():
|
||||
raise RuntimeError ("Comment fetch operation cancelled by user during retry delay.")
|
||||
time .sleep (0.1 )
|
||||
continue
|
||||
else :
|
||||
logger (f" ❌ Failed to fetch comments for post {post_id } after {max_retries +1 } attempts.")
|
||||
raise RuntimeError (f"Timeout or connection error fetching comments for post {post_id } from {comments_api_url }")
|
||||
|
||||
except requests .exceptions .RequestException as e :
|
||||
err_msg =f"Error fetching comments for post {post_id } from {comments_api_url }: {e }"
|
||||
if e .response is not None :
|
||||
err_msg +=f" (Status: {e .response .status_code }, Body: {e .response .text [:200 ]})"
|
||||
if isinstance (e ,requests .exceptions .ConnectionError )and ("Failed to resolve"in str (e )or "NameResolutionError"in str (e )):
|
||||
err_msg +="\n 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN."
|
||||
raise RuntimeError (err_msg )
|
||||
except ValueError as e :
|
||||
raise RuntimeError (f"Error decoding JSON from comments API for post {post_id } ({comments_api_url }): {e }. Response text: {response .text [:200 ]}")
|
||||
except Exception as e :
|
||||
raise RuntimeError (f"Unexpected error fetching comments for post {post_id } ({comments_api_url }): {e }")
|
||||
|
||||
raise RuntimeError (f"Failed to fetch comments for post {post_id } after all attempts.")
|
||||
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 ()
|
||||
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 :
|
||||
raise RuntimeError (f"Timeout fetching comments for post {post_id } from {comments_api_url }")
|
||||
except requests .exceptions .RequestException as e :
|
||||
err_msg =f"Error fetching comments for post {post_id } from {comments_api_url }: {e }"
|
||||
if e .response is not None :
|
||||
err_msg +=f" (Status: {e .response .status_code }, Body: {e .response .text [:200 ]})"
|
||||
if isinstance (e ,requests .exceptions .ConnectionError )and ("Failed to resolve"in str (e )or "NameResolutionError"in str (e )):
|
||||
err_msg +="\n 💡 This looks like a DNS resolution problem. Please check your internet connection, DNS settings, or VPN."
|
||||
raise RuntimeError (err_msg )
|
||||
except ValueError as e :
|
||||
raise RuntimeError (f"Error decoding JSON from comments API for post {post_id } ({comments_api_url }): {e }. Response text: {response .text [:200 ]}")
|
||||
except Exception as e :
|
||||
raise RuntimeError (f"Unexpected error fetching comments for post {post_id } ({comments_api_url }): {e }")
|
||||
def download_from_api (
|
||||
api_url_input ,
|
||||
logger =print ,
|
||||
@@ -844,8 +785,6 @@ class PostProcessorWorker :
|
||||
scan_content_for_images =False ,
|
||||
creator_download_folder_ignore_words =None ,
|
||||
manga_global_file_counter_ref =None ,
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
):
|
||||
self .post =post_data
|
||||
self .download_root =download_root
|
||||
@@ -895,8 +834,6 @@ class PostProcessorWorker :
|
||||
self .override_output_dir =override_output_dir
|
||||
self .scan_content_for_images =scan_content_for_images
|
||||
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words
|
||||
self.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
if self .compress_images and Image is None :
|
||||
|
||||
self .logger ("⚠️ Image compression disabled: Pillow library not found.")
|
||||
@@ -1752,48 +1689,7 @@ class PostProcessorWorker :
|
||||
|
||||
if not self .extract_links_only and self .use_post_subfolders :
|
||||
cleaned_post_title_for_sub =clean_folder_name (post_title )
|
||||
post_id_for_fallback =self .post .get ('id','unknown_id')
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
base_path_for_post_subfolder =determined_post_save_path_for_history
|
||||
|
||||
suffix_counter =0
|
||||
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 :
|
||||
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 :
|
||||
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 )
|
||||
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
|
||||
break
|
||||
|
||||
determined_post_save_path_for_history =os .path .join (base_path_for_post_subfolder ,final_post_subfolder_name )
|
||||
determined_post_save_path_for_history =os .path .join (determined_post_save_path_for_history ,cleaned_post_title_for_sub )
|
||||
|
||||
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
|
||||
@@ -2056,8 +1952,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 :
|
||||
|
||||
current_path_for_file_instance =os .path .join (current_path_for_file_instance ,final_post_subfolder_name )
|
||||
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 )
|
||||
|
||||
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
|
||||
@@ -2099,29 +1995,6 @@ class PostProcessorWorker :
|
||||
total_skipped_this_post +=1
|
||||
self ._emit_signal ('file_progress',"",None )
|
||||
|
||||
# After a post's files are all processed, update the session file to mark this post as done.
|
||||
if self.session_file_path and self.session_lock:
|
||||
try:
|
||||
with self.session_lock:
|
||||
if os.path.exists(self.session_file_path): # Only update if the session file exists
|
||||
# Read current state
|
||||
with open(self.session_file_path, 'r', encoding='utf-8') as f:
|
||||
session_data = json.load(f)
|
||||
|
||||
# Modify in memory
|
||||
if not isinstance(session_data.get('download_state', {}).get('processed_post_ids'), list):
|
||||
if 'download_state' not in session_data:
|
||||
session_data['download_state'] = {}
|
||||
session_data['download_state']['processed_post_ids'] = []
|
||||
session_data['download_state']['processed_post_ids'].append(self.post.get('id'))
|
||||
# Write to temp file and then atomically replace
|
||||
temp_file_path = self.session_file_path + ".tmp"
|
||||
with open(temp_file_path, 'w', encoding='utf-8') as f_tmp:
|
||||
json.dump(session_data, f_tmp, indent=2)
|
||||
os.replace(temp_file_path, self.session_file_path)
|
||||
except Exception as e:
|
||||
self.logger(f"⚠️ Could not update session file for post {post_id}: {e}")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2148,22 +2021,6 @@ 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 ):
|
||||
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 )
|
||||
@@ -2209,8 +2066,6 @@ class DownloadThread (QThread ):
|
||||
scan_content_for_images =False ,
|
||||
creator_download_folder_ignore_words =None ,
|
||||
cookie_text ="",
|
||||
session_file_path=None,
|
||||
session_lock=None,
|
||||
):
|
||||
super ().__init__ ()
|
||||
self .api_url_input =api_url_input
|
||||
@@ -2261,9 +2116,7 @@ class DownloadThread (QThread ):
|
||||
self .scan_content_for_images =scan_content_for_images
|
||||
self .creator_download_folder_ignore_words =creator_download_folder_ignore_words
|
||||
self .manga_global_file_counter_ref =manga_global_file_counter_ref
|
||||
self.session_file_path = session_file_path
|
||||
self.session_lock = session_lock
|
||||
self.history_candidates_buffer =deque (maxlen =8 )
|
||||
self .history_candidates_buffer =deque (maxlen =8 )
|
||||
if self .compress_images and Image is None :
|
||||
self .logger ("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
|
||||
self .compress_images =False
|
||||
@@ -2393,8 +2246,6 @@ class DownloadThread (QThread ):
|
||||
use_cookie =self .use_cookie ,
|
||||
manga_date_file_counter_ref =self .manga_date_file_counter_ref ,
|
||||
creator_download_folder_ignore_words =self .creator_download_folder_ignore_words ,
|
||||
session_file_path=self.session_file_path,
|
||||
session_lock=self.session_lock,
|
||||
)
|
||||
try :
|
||||
dl_count ,skip_count ,kept_originals_this_post ,retryable_failures ,permanent_failures ,history_data =post_processing_worker .process ()
|
||||
|
||||
@@ -214,7 +214,7 @@ Controls for how downloaded content is structured into folders.
|
||||
- **⤵️ Add to Filter Button:** Opens a dialog displaying all entries from `Known.txt` (with a search bar). Select one or more entries to add them to the "**🎯 Filter by Character(s)**" input field. Grouped names from `Known.txt` are added with the `~` syntax if applicable.
|
||||
- **🗑️ Delete Selected Button:** Removes the currently selected name(s) from the list display and from the `Known.txt` file.
|
||||
- **Open Known.txt Button:** Opens your `Known.txt` file in the system's default text editor for manual editing.
|
||||
- **❓ Help Button:** Opens a guide or tooltip explaining the app feature
|
||||
- **❓ Help Button (Known.txt):** Opens a guide or tooltip explaining the `Known.txt` feature and syntax.
|
||||
|
||||
---
|
||||
|
||||
@@ -388,4 +388,4 @@ These settings allow you to customize the application's appearance and language.
|
||||
|
||||
---
|
||||
|
||||
This guide should cover all interactive elements of the Kemono Downloader. If you have further questions or discover elements not covered, please refer to the main `readme.md` or consider opening an issue on the project's repository.
|
||||
This guide should cover all interactive elements of the Kemono Downloader. If you have further questions or discover elements not covered, please refer to the main `readme.md` or consider opening an issue on the project's repository.
|
||||
6220
languages.py
6220
languages.py
File diff suppressed because one or more lines are too long
556
main.py
556
main.py
@@ -178,7 +178,6 @@ 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"
|
||||
@@ -720,11 +719,6 @@ 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 ()
|
||||
@@ -746,9 +740,6 @@ 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":
|
||||
@@ -821,29 +812,6 @@ 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"
|
||||
@@ -1177,6 +1145,8 @@ 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 ):
|
||||
@@ -1249,8 +1219,16 @@ 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 :
|
||||
@@ -2138,24 +2116,8 @@ class KnownNamesFilterDialog (QDialog ):
|
||||
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 ))
|
||||
|
||||
self .setMinimumWidth (350 )
|
||||
self .setMinimumHeight (400 )
|
||||
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 )
|
||||
@@ -2175,12 +2137,6 @@ 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 :
|
||||
@@ -2189,15 +2145,16 @@ 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 ()
|
||||
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 )
|
||||
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 )
|
||||
|
||||
def _accept_selection_action (self ):
|
||||
self .selected_entries_to_return =[]
|
||||
@@ -3636,11 +3593,7 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
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 .cancellation_event =threading .Event ()
|
||||
self .external_link_download_thread =None
|
||||
self .pause_event =threading .Event ()
|
||||
self .active_futures =[]
|
||||
@@ -3651,8 +3604,11 @@ class DownloaderApp (QWidget ):
|
||||
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")
|
||||
app_data_path =QStandardPaths .writableLocation (QStandardPaths .AppDataLocation )
|
||||
self .last_downloaded_files_details =deque (maxlen =3 )
|
||||
if not app_data_path :
|
||||
app_data_path =os .path .join (self .app_base_dir ,"app_data")
|
||||
self .persistent_history_file =os .path .join (app_data_path ,CONFIG_ORGANIZATION_NAME ,CONFIG_APP_NAME_MAIN ,"download_history.json")
|
||||
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 =[]
|
||||
@@ -3773,7 +3729,7 @@ class DownloaderApp (QWidget ):
|
||||
self .remove_from_filename_label_widget =None
|
||||
self .skip_words_label_widget =None
|
||||
|
||||
self .setWindowTitle ("Kemono Downloader v5.5.0")
|
||||
self .setWindowTitle ("Kemono Downloader v5.0.0")
|
||||
|
||||
self .init_ui ()
|
||||
self ._connect_signals ()
|
||||
@@ -3791,60 +3747,6 @@ 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 ()
|
||||
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 =""):
|
||||
@@ -3853,140 +3755,15 @@ 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 _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 _initialize_persistent_history_path (self ):
|
||||
documents_path =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation )
|
||||
if not documents_path :
|
||||
self .log_signal .emit ("⚠️ DocumentsLocation not found. Falling back to app base directory for history.")
|
||||
documents_path =self .app_base_dir
|
||||
|
||||
history_folder_name ="history"
|
||||
self .persistent_history_file =os .path .join (documents_path ,history_folder_name ,"download_history.json")
|
||||
self .log_signal .emit (f"ℹ️ Persistent history file path set to: {self .persistent_history_file }")
|
||||
|
||||
def _retranslate_main_ui (self ):
|
||||
"""Retranslates static text elements in the main UI."""
|
||||
@@ -4870,7 +4647,7 @@ class DownloaderApp (QWidget ):
|
||||
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 .history_button .setToolTip (self ._tr ("history_button_tooltip_text","View download history (Not Implemented Yet)"))
|
||||
|
||||
self .future_settings_button =QPushButton ("⚙️")
|
||||
self .future_settings_button .setFixedWidth (35 )
|
||||
@@ -5030,26 +4807,19 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def _load_persistent_history (self ):
|
||||
"""Loads download history from a persistent file."""
|
||||
self ._initialize_persistent_history_path ()
|
||||
file_existed_before_load =os .path .exists (self .persistent_history_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 :
|
||||
loaded_history =json .load (f )
|
||||
if isinstance (loaded_history ,list ):
|
||||
self .final_download_history_entries =loaded_history
|
||||
self .log_signal .emit (f"✅ Loaded {len (loaded_history )} entries from persistent download history: {self .persistent_history_file }")
|
||||
elif loaded_history 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 =[]
|
||||
@@ -5066,6 +4836,8 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
def _save_persistent_history (self ):
|
||||
"""Saves download history to a persistent file."""
|
||||
if not hasattr (self ,'persistent_history_file')or not self .persistent_history_file :
|
||||
self ._initialize_persistent_history_path ()
|
||||
self .log_signal .emit (f"📜 Attempting to save history to: {self .persistent_history_file }")
|
||||
try :
|
||||
history_dir =os .path .dirname (self .persistent_history_file )
|
||||
@@ -5073,13 +4845,9 @@ class DownloaderApp (QWidget ):
|
||||
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 )
|
||||
json .dump (self .final_download_history_entries ,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 }")
|
||||
@@ -6222,7 +5990,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
|
||||
can_enable_subfolder_per_post_checkbox =not is_only_links and not is_only_archives
|
||||
|
||||
if self .use_subfolder_per_post_checkbox :
|
||||
self .use_subfolder_per_post_checkbox .setEnabled (can_enable_subfolder_per_post_checkbox )
|
||||
@@ -6503,11 +6271,11 @@ class DownloaderApp (QWidget ):
|
||||
self .file_progress_label .setText ("")
|
||||
|
||||
|
||||
def start_download (self ,direct_api_url =None ,override_output_dir =None, is_restore=False ):
|
||||
def start_download (self ,direct_api_url =None ,override_output_dir =None ):
|
||||
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.")
|
||||
QMessageBox .warning (self ,"Busy","A download is already running.")
|
||||
return False
|
||||
|
||||
|
||||
@@ -6518,14 +6286,11 @@ class DownloaderApp (QWidget ):
|
||||
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 :
|
||||
@@ -6882,6 +6647,14 @@ class DownloaderApp (QWidget ):
|
||||
creator_folder_ignore_words_for_run =None
|
||||
is_full_creator_download =not post_id_from_url
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if compress_images and Image is None :
|
||||
QMessageBox .warning (self ,"Missing Dependency","Pillow library (for image compression) not found. Compression will be disabled.")
|
||||
compress_images =False ;self .compress_images_checkbox .setChecked (False )
|
||||
@@ -7218,8 +6991,6 @@ class DownloaderApp (QWidget ):
|
||||
'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 ,
|
||||
}
|
||||
|
||||
@@ -7237,8 +7008,7 @@ class DownloaderApp (QWidget ):
|
||||
'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',
|
||||
'downloaded_files_lock','downloaded_file_hashes_lock','dynamic_character_filter_holder',
|
||||
'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',
|
||||
@@ -7251,7 +7021,6 @@ class DownloaderApp (QWidget ):
|
||||
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 ,[])
|
||||
@@ -7259,21 +7028,6 @@ class DownloaderApp (QWidget ):
|
||||
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
|
||||
@@ -7300,7 +7054,6 @@ class DownloaderApp (QWidget ):
|
||||
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 }")
|
||||
@@ -7378,52 +7131,6 @@ class DownloaderApp (QWidget ):
|
||||
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 :
|
||||
@@ -7444,7 +7151,7 @@ class DownloaderApp (QWidget ):
|
||||
)
|
||||
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
|
||||
@@ -7452,80 +7159,45 @@ class DownloaderApp (QWidget ):
|
||||
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
|
||||
)
|
||||
)
|
||||
try :
|
||||
self .log_signal .emit (" Fetching post data from API (this may take a moment for large feeds)...")
|
||||
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
|
||||
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)
|
||||
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 }")
|
||||
unique_posts_dict ={}
|
||||
for post in all_posts_data :
|
||||
post_id =post .get ('id')
|
||||
@@ -7535,14 +7207,12 @@ class DownloaderApp (QWidget ):
|
||||
else :
|
||||
self .log_signal .emit (f"⚠️ Skipping post with no ID: {post .get ('title','Untitled')}")
|
||||
|
||||
posts_to_process_final = list(unique_posts_dict.values())
|
||||
all_posts_data =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
|
||||
self .total_posts_to_process =len (all_posts_data )
|
||||
self .log_signal .emit (f" Processed {len (unique_posts_dict )} unique posts after de-duplication.")
|
||||
if len (unique_posts_dict )<len (all_posts_data ):
|
||||
self .log_signal .emit (f" Note: {len (all_posts_data )-len (unique_posts_dict )} duplicate post IDs were removed.")
|
||||
|
||||
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
|
||||
@@ -7560,15 +7230,14 @@ class DownloaderApp (QWidget ):
|
||||
if self .thread_pool :self .thread_pool .shutdown (wait =False ,cancel_futures =True );self .thread_pool =None
|
||||
return
|
||||
|
||||
if not all_posts_data:
|
||||
if self .total_posts_to_process ==0 :
|
||||
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)
|
||||
self .processed_posts_count =0
|
||||
self .overall_progress_signal .emit (self .total_posts_to_process ,0 )
|
||||
|
||||
num_file_dl_threads_for_each_worker =worker_args_template .get ('num_file_threads_for_worker',1 )
|
||||
|
||||
@@ -7587,7 +7256,6 @@ class DownloaderApp (QWidget ):
|
||||
'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 ={
|
||||
@@ -7695,8 +7363,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
|
||||
@@ -7944,14 +7612,11 @@ class DownloaderApp (QWidget ):
|
||||
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
|
||||
|
||||
@@ -7968,7 +7633,6 @@ class DownloaderApp (QWidget ):
|
||||
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 )
|
||||
@@ -8003,7 +7667,6 @@ class DownloaderApp (QWidget ):
|
||||
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 ()
|
||||
@@ -8058,11 +7721,6 @@ class DownloaderApp (QWidget ):
|
||||
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 :
|
||||
@@ -8647,12 +8305,6 @@ class DownloaderApp (QWidget ):
|
||||
|
||||
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 :
|
||||
@@ -8940,4 +8592,4 @@ if __name__ =='__main__':
|
||||
print ("--- CRITICAL APPLICATION ERROR ---")
|
||||
print (f"An unhandled exception occurred: {e }")
|
||||
traceback .print_exc ()
|
||||
print ("--- END CRITICAL ERROR ---")
|
||||
print ("--- END CRITICAL ERROR ---")
|
||||
16
readme.md
16
readme.md
@@ -1,25 +1,31 @@
|
||||
<h1 align="center">Kemono Downloader v5.5.0</h1>
|
||||
<h1 align="center">Kemono Downloader v5.3.0</h1>
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="Read/Read.png" alt="Default Mode" width="400"/><br>
|
||||
<img src="Read/Read.png" alt="Post Downloader Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Default</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="Read/Read1.png" alt="Favorite Mode" width="400"/><br>
|
||||
<img src="Read/Read1.png" alt="Creator Downloader Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Favorite mode</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="Read/Read2.png" alt="Single Post" width="400"/><br>
|
||||
<img src="Read/Read2.png" alt="Settings Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Single Post</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="Read/Read3.png" alt="Manga/Comic Mode" width="400"/><br>
|
||||
<img src="Read/Read3.png" alt="Settings Tab" width="400"/>
|
||||
<br>
|
||||
<strong>Manga/Comic Mode</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user