mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68bab40d9 | ||
|
|
3fc2cfde99 | ||
|
|
304ad2b3c1 | ||
|
|
64a314713e |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -11,6 +11,7 @@ from concurrent .futures import ThreadPoolExecutor ,Future ,CancelledError ,as_c
|
||||
import html
|
||||
from PyQt5 .QtCore import QObject ,pyqtSignal ,QThread ,QMutex ,QMutexLocker
|
||||
from urllib .parse import urlparse
|
||||
import uuid
|
||||
try :
|
||||
from mega import Mega
|
||||
|
||||
@@ -1016,6 +1017,14 @@ class PostProcessorWorker :
|
||||
except OSError as e :
|
||||
self .logger (f" ❌ Critical error creating directory '{target_folder_path }': {e }. Skipping file '{api_original_filename }'.")
|
||||
return 0 ,1 ,api_original_filename ,False ,FILE_DOWNLOAD_STATUS_SKIPPED ,None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
temp_file_base_for_unique_part ,temp_file_ext_for_unique_part =os .path .splitext (filename_to_save_in_main_path if filename_to_save_in_main_path else api_original_filename )
|
||||
unique_id_for_part_file =uuid .uuid4 ().hex [:8 ]
|
||||
unique_part_file_stem_on_disk =f"{temp_file_base_for_unique_part }_{unique_id_for_part_file }"
|
||||
max_retries =3
|
||||
retry_delay =5
|
||||
downloaded_size_bytes =0
|
||||
@@ -1026,8 +1035,9 @@ class PostProcessorWorker :
|
||||
download_successful_flag =False
|
||||
last_exception_for_retry_later =None
|
||||
|
||||
|
||||
response_for_this_attempt =None
|
||||
for attempt_num_single_stream in range (max_retries +1 ):
|
||||
response_for_this_attempt =None
|
||||
if self ._check_pause (f"File download attempt for '{api_original_filename }'"):break
|
||||
if self .check_cancel ()or (skip_event and skip_event .is_set ()):break
|
||||
try :
|
||||
@@ -1045,11 +1055,17 @@ class PostProcessorWorker :
|
||||
if self ._check_pause (f"Multipart decision for '{api_original_filename }'"):break
|
||||
|
||||
if attempt_multipart :
|
||||
response .close ()
|
||||
self ._emit_signal ('file_download_status',False )
|
||||
mp_save_path_base_for_part =os .path .join (target_folder_path ,filename_to_save_in_main_path )
|
||||
if response_for_this_attempt :
|
||||
response_for_this_attempt .close ()
|
||||
response_for_this_attempt =None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
mp_save_path_for_unique_part_stem_arg =os .path .join (target_folder_path ,f"{unique_part_file_stem_on_disk }{temp_file_ext_for_unique_part }")
|
||||
mp_success ,mp_bytes ,mp_hash ,mp_file_handle =download_file_in_parts (
|
||||
file_url ,mp_save_path_base_for_part ,total_size_bytes ,num_parts_for_file ,headers ,api_original_filename ,
|
||||
file_url ,mp_save_path_for_unique_part_stem_arg ,total_size_bytes ,num_parts_for_file ,headers ,api_original_filename ,
|
||||
emitter_for_multipart =self .emitter ,cookies_for_chunk_session =cookies_to_use_for_file ,
|
||||
cancellation_event =self .cancellation_event ,skip_event =skip_event ,logger_func =self .logger ,
|
||||
pause_event =self .pause_event
|
||||
@@ -1060,7 +1076,8 @@ class PostProcessorWorker :
|
||||
calculated_file_hash =mp_hash
|
||||
|
||||
|
||||
downloaded_part_file_path =mp_save_path_base_for_part +".part"
|
||||
|
||||
downloaded_part_file_path =mp_save_path_for_unique_part_stem_arg +".part"
|
||||
was_multipart_download =True
|
||||
if mp_file_handle :mp_file_handle .close ()
|
||||
break
|
||||
@@ -1071,11 +1088,13 @@ class PostProcessorWorker :
|
||||
download_successful_flag =False ;break
|
||||
else :
|
||||
self .logger (f"⬇️ Downloading (Single Stream): '{api_original_filename }' (Size: {total_size_bytes /(1024 *1024 ):.2f} MB if known) [Base Name: '{filename_to_save_in_main_path }']")
|
||||
current_single_stream_part_path =os .path .join (target_folder_path ,filename_to_save_in_main_path +".part")
|
||||
|
||||
current_single_stream_part_path =os .path .join (target_folder_path ,f"{unique_part_file_stem_on_disk }{temp_file_ext_for_unique_part }.part")
|
||||
current_attempt_downloaded_bytes =0
|
||||
md5_hasher =hashlib .md5 ()
|
||||
last_progress_time =time .time ()
|
||||
|
||||
single_stream_exception =None
|
||||
try :
|
||||
with open (current_single_stream_part_path ,'wb')as f_part :
|
||||
for chunk in response .iter_content (chunk_size =1 *1024 *1024 ):
|
||||
@@ -1093,40 +1112,45 @@ class PostProcessorWorker :
|
||||
if os .path .exists (current_single_stream_part_path ):os .remove (current_single_stream_part_path )
|
||||
break
|
||||
|
||||
# Determine if this single-stream download attempt was complete
|
||||
attempt_is_complete = False
|
||||
if response.status_code == 200: # Ensure basic success
|
||||
if total_size_bytes > 0: # Content-Length was provided
|
||||
if current_attempt_downloaded_bytes == total_size_bytes:
|
||||
attempt_is_complete = True
|
||||
else:
|
||||
self.logger(f" ⚠️ Single-stream attempt for '{api_original_filename}' incomplete: received {current_attempt_downloaded_bytes} of {total_size_bytes} bytes.")
|
||||
elif total_size_bytes == 0: # Server reported 0-byte file (Content-Length: 0)
|
||||
if current_attempt_downloaded_bytes == 0: # And we got 0 bytes
|
||||
attempt_is_complete = True
|
||||
else: # Server said 0 bytes, but we got some.
|
||||
self.logger(f" ⚠️ Mismatch for '{api_original_filename}': Server reported 0 bytes, but received {current_attempt_downloaded_bytes} bytes this attempt.")
|
||||
# Case: No Content-Length header, so total_size_bytes became 0 from int(headers.get('Content-Length',0)).
|
||||
# And we actually received some bytes.
|
||||
elif current_attempt_downloaded_bytes > 0 : # Implicitly total_size_bytes == 0 here due to previous conditions
|
||||
attempt_is_complete = True
|
||||
self.logger(f" ⚠️ Single-stream for '{api_original_filename}' received {current_attempt_downloaded_bytes} bytes (no Content-Length from server). Assuming complete for this attempt as stream ended.")
|
||||
|
||||
if attempt_is_complete:
|
||||
calculated_file_hash = md5_hasher.hexdigest()
|
||||
downloaded_size_bytes = current_attempt_downloaded_bytes
|
||||
downloaded_part_file_path = current_single_stream_part_path
|
||||
was_multipart_download = False # Ensure this is set for single stream success
|
||||
download_successful_flag = True # Mark THE ENTIRE DOWNLOAD as successful
|
||||
break # Break from the RETRY loop (attempt_num_single_stream)
|
||||
else: # This attempt was not successful (e.g., incomplete or 0 bytes when not expected)
|
||||
if os .path .exists (current_single_stream_part_path ):os .remove (current_single_stream_part_path )
|
||||
# Let the retry loop continue if more attempts are left; download_successful_flag remains False for this attempt.
|
||||
attempt_is_complete =False
|
||||
if response .status_code ==200 :
|
||||
if total_size_bytes >0 :
|
||||
if current_attempt_downloaded_bytes ==total_size_bytes :
|
||||
attempt_is_complete =True
|
||||
else :
|
||||
self .logger (f" ⚠️ Single-stream attempt for '{api_original_filename }' incomplete: received {current_attempt_downloaded_bytes } of {total_size_bytes } bytes.")
|
||||
elif total_size_bytes ==0 :
|
||||
if current_attempt_downloaded_bytes ==0 :
|
||||
attempt_is_complete =True
|
||||
else :
|
||||
self .logger (f" ⚠️ Mismatch for '{api_original_filename }': Server reported 0 bytes, but received {current_attempt_downloaded_bytes } bytes this attempt.")
|
||||
|
||||
|
||||
elif current_attempt_downloaded_bytes >0 :
|
||||
attempt_is_complete =True
|
||||
self .logger (f" ⚠️ Single-stream for '{api_original_filename }' received {current_attempt_downloaded_bytes } bytes (no Content-Length from server). Assuming complete for this attempt as stream ended.")
|
||||
|
||||
if attempt_is_complete :
|
||||
calculated_file_hash =md5_hasher .hexdigest ()
|
||||
downloaded_size_bytes =current_attempt_downloaded_bytes
|
||||
downloaded_part_file_path =current_single_stream_part_path
|
||||
was_multipart_download =False
|
||||
download_successful_flag =True
|
||||
break
|
||||
else :
|
||||
if os .path .exists (current_single_stream_part_path ):
|
||||
try :os .remove (current_single_stream_part_path )
|
||||
except OSError as e_rem_part :self .logger (f" -> Failed to remove .part file after failed single stream attempt: {e_rem_part }")
|
||||
|
||||
except Exception as e_write :
|
||||
self .logger (f" ❌ Error writing single-stream to disk for '{api_original_filename }': {e_write }")
|
||||
if os .path .exists (current_single_stream_part_path ):os .remove (current_single_stream_part_path )
|
||||
|
||||
raise
|
||||
single_stream_exception =e_write
|
||||
if single_stream_exception :
|
||||
raise single_stream_exception
|
||||
|
||||
except (requests .exceptions .ConnectionError ,requests .exceptions .Timeout ,http .client .IncompleteRead )as e :
|
||||
self .logger (f" ❌ Download Error (Retryable): {api_original_filename }. Error: {e }")
|
||||
@@ -1145,6 +1169,8 @@ class PostProcessorWorker :
|
||||
last_exception_for_retry_later =e
|
||||
break
|
||||
finally :
|
||||
if response_for_this_attempt :
|
||||
response_for_this_attempt .close ()
|
||||
self ._emit_signal ('file_download_status',False )
|
||||
|
||||
final_total_for_progress =total_size_bytes if download_successful_flag and total_size_bytes >0 else downloaded_size_bytes
|
||||
@@ -1263,19 +1289,20 @@ class PostProcessorWorker :
|
||||
|
||||
final_filename_on_disk =filename_after_compression
|
||||
|
||||
if not (self .manga_mode_active and self .manga_filename_style ==STYLE_DATE_BASED ):
|
||||
temp_base ,temp_ext =os .path .splitext (final_filename_on_disk )
|
||||
suffix_counter =1
|
||||
while os .path .exists (os .path .join (effective_save_folder ,final_filename_on_disk )):
|
||||
final_filename_on_disk =f"{temp_base }_{suffix_counter }{temp_ext }"
|
||||
suffix_counter +=1
|
||||
if final_filename_on_disk !=filename_after_compression :
|
||||
self .logger (f" Applied numeric suffix in '{os .path .basename (effective_save_folder )}': '{final_filename_on_disk }' (was '{filename_after_compression }')")
|
||||
|
||||
temp_base ,temp_ext =os .path .splitext (final_filename_on_disk )
|
||||
suffix_counter =1
|
||||
while os .path .exists (os .path .join (effective_save_folder ,final_filename_on_disk )):
|
||||
final_filename_on_disk =f"{temp_base }_{suffix_counter }{temp_ext }"
|
||||
suffix_counter +=1
|
||||
if final_filename_on_disk !=filename_after_compression :
|
||||
self .logger (f" Applied numeric suffix in '{os .path .basename (effective_save_folder )}': '{final_filename_on_disk }' (was '{filename_after_compression }')")
|
||||
if self ._check_pause (f"File saving for '{final_filename_on_disk }'"):return 0 ,1 ,final_filename_on_disk ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SKIPPED ,None
|
||||
final_save_path =os .path .join (effective_save_folder ,final_filename_on_disk )
|
||||
try :
|
||||
if data_to_write_io :
|
||||
with open (final_save_path ,'wb')as f_out :
|
||||
time .sleep (0.05 )
|
||||
f_out .write (data_to_write_io .getvalue ())
|
||||
|
||||
if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ):
|
||||
@@ -1285,6 +1312,7 @@ class PostProcessorWorker :
|
||||
self .logger (f" -> Failed to remove .part after compression: {e_rem }")
|
||||
else :
|
||||
if downloaded_part_file_path and os .path .exists (downloaded_part_file_path ):
|
||||
time .sleep (0.1 )
|
||||
os .rename (downloaded_part_file_path ,final_save_path )
|
||||
else :
|
||||
raise FileNotFoundError (f"Original .part file not found for saving: {downloaded_part_file_path }")
|
||||
@@ -1295,7 +1323,7 @@ class PostProcessorWorker :
|
||||
time .sleep (0.05 )
|
||||
return 1 ,0 ,final_filename_saved_for_return ,was_original_name_kept_flag ,FILE_DOWNLOAD_STATUS_SUCCESS ,None
|
||||
except Exception as save_err :
|
||||
self .logger (f"❌ Save Fail for '{final_filename_on_disk }': {save_err }")
|
||||
self .logger (f"->>Save Fail for '{final_filename_on_disk }': {save_err }")
|
||||
if os .path .exists (final_save_path ):
|
||||
try :os .remove (final_save_path );
|
||||
except OSError :self .logger (f" -> Failed to remove partially saved file: {final_save_path }")
|
||||
|
||||
40
features.md
40
features.md
@@ -323,6 +323,46 @@ Download directly from your favorited artists and posts on Kemono.su.
|
||||
- **Note:** Files successfully retried or skipped due to hash match during a retry attempt are removed from this error list.
|
||||
---
|
||||
|
||||
## ⚙️ Application Settings
|
||||
|
||||
These settings allow you to customize the application's appearance and language.
|
||||
|
||||
- **⚙️ Settings Button (Icon may vary, e.g., a gear ⚙️):**
|
||||
- **Location:** Typically located in a persistent area of the UI, possibly near other global controls or in a menu.
|
||||
- **Purpose:** Opens the "Settings" dialog.
|
||||
- **Tooltip Example:** "Open application settings (Theme, Language, etc.)"
|
||||
|
||||
- **"Settings" Dialog:**
|
||||
- **Title:** "Settings"
|
||||
- **Purpose:** Provides options to configure application-wide preferences.
|
||||
- **Sections:**
|
||||
- **Appearance Group (`Appearance`):**
|
||||
- **Theme Toggle Buttons/Options:**
|
||||
- `Switch to Light Mode`
|
||||
- `Switch to Dark Mode`
|
||||
- **Purpose:** Allows users to switch between a light and dark visual theme for the application.
|
||||
- **Tooltips:** Provide guidance on switching themes.
|
||||
- **Language Settings Group (`Language Settings`):**
|
||||
- **Language Selection Dropdown/List:**
|
||||
- **Label:** "Language:"
|
||||
- **Options:** Includes, but not limited to:
|
||||
- English (`English`)
|
||||
- 日本語 (`日本語 (Japanese)`)
|
||||
- Français (French)
|
||||
- Español (Spanish)
|
||||
- Deutsch (German)
|
||||
- Русский (Russian)
|
||||
- 한국어 (Korean)
|
||||
- 简体中文 (Chinese Simplified)
|
||||
- **Purpose:** Allows users to change the display language of the application interface.
|
||||
- **Restart Prompt:** After changing the language, a dialog may appear:
|
||||
- **Title:** "Language Changed"
|
||||
- **Message:** "The language has been changed. A restart is required for all changes to take full effect."
|
||||
- **Informative Text:** "Would you like to restart the application now?"
|
||||
- **Buttons:** "Restart Now", "OK" (or similar to defer restart).
|
||||
- **"OK" Button:** Saves the changes made in the Settings dialog and closes it.
|
||||
---
|
||||
|
||||
## Other UI Elements
|
||||
|
||||
- **Retry Failed Downloads Prompt:**
|
||||
|
||||
4233
languages.py
Normal file
4233
languages.py
Normal file
File diff suppressed because one or more lines are too long
13
readme.md
13
readme.md
@@ -1,4 +1,4 @@
|
||||
<h1 align="center">Kemono Downloader v5.1.0</h1>
|
||||
<h1 align="center">Kemono Downloader v5.2.0</h1>
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
@@ -34,7 +34,7 @@ A powerful, feature-rich GUI application for downloading content from **[Kemono.
|
||||
Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation, all within a modern and user-friendly graphical interface.
|
||||
|
||||
*This v5.0.0 release marks a significant feature milestone. Future updates are expected to be less frequent, focusing on maintenance and minor refinements.*
|
||||
*Update v5.1.0 enhances error handling and UI responsiveness.*
|
||||
*Update v5.2.0 introduces multi-language support, theme selection, and further UI refinements.*
|
||||
<p align="center">
|
||||
<a href="features.md"><strong>📚 Full Feature List</strong></a> •
|
||||
<a href="LICENSE"><strong>📝 License</strong></a>
|
||||
@@ -75,6 +75,8 @@ Kemono Downloader offers a range of features to streamline your content download
|
||||
- **Multithreading:** Configure the number of simultaneous downloads/post processing threads for improved speed.
|
||||
- **Logging:**
|
||||
- A detailed progress log displays download activity, errors, and summaries.
|
||||
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified).
|
||||
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
|
||||
|
||||
---
|
||||
|
||||
@@ -84,6 +86,13 @@ Kemono Downloader offers a range of features to streamline your content download
|
||||
- A new **"Export URLs to .txt"** button, allowing users to save links of failed downloads either as "URL only" or "URL with details" (including post title, ID, and original filename).
|
||||
- Fixed a bug where files skipped during retry (due to existing hash match) were not correctly removed from the error list.
|
||||
- **Improved UI Stability**: Addressed issues with UI state management to more accurately reflect ongoing download activities (including retries and external link downloads). This prevents the "Cancel" button from becoming inactive prematurely while operations are still running.
|
||||
|
||||
## ✨ What's New in v5.2.0
|
||||
- **Multi-language Support:** The interface now supports multiple languages: English, Japanese, French, Spanish, German, Russian, Korean, and Chinese (Simplified). Select your preferred language in the new Settings dialog.
|
||||
- **Theme Selection:** Choose between Light and Dark application themes via the Settings dialog for a personalized viewing experience.
|
||||
- **Centralized Settings:** A new Settings dialog (accessible via a settings button, often with a gear icon) provides a dedicated space for language and appearance customizations.
|
||||
- **Internal Localization:** Introduced `languages.py` for managing UI translations, streamlining the addition of new languages by contributors.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Reference in New Issue
Block a user