4 Commits

Author SHA1 Message Date
Yuvi9587
d68bab40d9 commit 2025-06-10 17:58:41 +01:00
Yuvi9587
3fc2cfde99 Commit 2025-06-10 17:21:50 +01:00
Yuvi9587
304ad2b3c1 Commit 2025-06-10 16:16:20 +01:00
Yuvi9587
64a314713e Update downloader_utils.py 2025-06-09 16:22:27 +01:00
7 changed files with 5472 additions and 1236 deletions

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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 }")

View File

@@ -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

File diff suppressed because one or more lines are too long

2288
main.py

File diff suppressed because it is too large Load Diff

View File

@@ -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