1 Commits

Author SHA1 Message Date
Yuvi9587
5d8737b59e Update LICENSE 2025-07-22 10:01:58 -07:00
6 changed files with 353 additions and 358 deletions

View File

@@ -3,7 +3,7 @@ MIT License
Copyright (c) [2025] [Yuvi9587] Copyright (c) [2025] [Yuvi9587]
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the Software), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
@@ -12,7 +12,7 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER

View File

@@ -1,147 +1,192 @@
<div> # Kemono Downloader - Feature Guide
<h1>Kemono Downloader - Comprehensive Feature Guide</h1> This guide provides a comprehensive overview of all user interface elements, input fields, buttons, popups, and functionalities available in the Kemono Downloader.
<p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
<hr> ## 1. Main Interface & Workflow
<h2><strong>Main Window: Core Functionality</strong></h2> These are the primary controls you'll interact with to initiate and manage downloads.
<p>The application is divided into a configuration panel on the left and a status/log panel on the right.</p>
<h3><strong>Primary Inputs (Top-Left)</strong></h3> ### 1.1. Core Inputs
<ul> **🔗 Creator/Post URL Input Field**  
<li><strong>URL Input Field</strong>: This is the starting point for most downloads. You can paste a URL for a specific post or for an entire creator's feed. The application's behavior adapts based on the URL type.</li> - **Purpose**: Paste the URL of the content you want to download.  
<li><strong>🎨 Creator Selection Popup</strong>: This button opens a powerful dialog listing all known creators. From here, you can: - **Supported Sites**: Kemono.su, Coomer.party, Simpcity.su.  
<ul> - **Supported URL Types**:  
<li><strong>Search and Queue</strong>: Search for creators and check multiple names. Clicking "Add Selected" populates the main input field, preparing a batch download.</li>   - Creator pages (e.g., `https://kemono.su/patreon/user/12345`).  
<li><strong>Check for Updates</strong>: Select a single creator's saved profile. This loads their information and switches the main download button to "Check for Updates" mode, allowing you to download only new content since your last session.</li>   - Individual posts (e.g., `https://kemono.su/patreon/user/12345/post/98765`).  
</ul> - **Note**: When ⭐ Favorite Mode is active, this field is disabled. For Simpcity.su URLs, the "Use Cookie" option is mandatory and auto-enabled.
</li>
<li><strong>Download Location</strong>: The primary folder where all content will be saved. The <strong>Browse...</strong> button lets you select this folder from your computer.</li> **🎨 Creator Selection Button**  
<li><strong>Page Range (Start/End)</strong>: These fields activate only for creator feed URLs. They allow you to download a specific slice of a creator's history (e.g., pages 5 through 10) instead of their entire feed.</li> - **Icon**: 🎨 (Artist Palette)  
</ul> - **Purpose**: Opens the "Creator Selection" dialog to browse and queue downloads from known creators.  
<hr> - **Dialog Features**:  
<h2><strong>Filtering & Naming (Left Panel)</strong></h2>   - Loads creators from `creators.json`.  
<p>These features give you precise control over what gets downloaded and how it's named and organized.</p>   - **Search Bar**: Filter creators by name.  
<ul>   - **Creator List**: Displays creators with their service (e.g., Patreon, Fanbox).  
<li><strong>Filter by Character(s)</strong>: A powerful tool to download content featuring specific characters. You can enter multiple names separated by commas.   - **Selection**: Checkboxes to select one or more creators.  
<ul>   - **Download Scope**: Organize downloads by Characters or Creators.  
<li><strong>Filter: [Scope] Button</strong>: This button changes how the character filter works:   - **Add to Queue**: Adds selected creators or their posts to the download queue.
<ul>
<li><strong>Title</strong>: Downloads posts only if a character's name is in the post title.</li> **Page Range (Start to End) Input Fields**  
<li><strong>Files</strong>: Downloads posts if a character's name is in any of the filenames within the post.</li> - **Purpose**: Specify a range of pages to fetch for creator URLs.  
<li><strong>Both</strong>: Combines the "Title" and "Files" logic.</li> - **Usage**: Enter the starting and ending page numbers.  
<li><strong>Comments (Beta)</strong>: Downloads a post if a character's name is mentioned in the comments section.</li> - **Behavior**:  
</ul>   - If blank, all pages are processed.  
</li>   - Disabled for single post URLs.
</ul>
</li> **📁 Download Location Input Field & Browse Button**  
<li><strong>Skip with Words</strong>: A keyword-based filter to avoid unwanted content (e.g., <code>WIP</code>, <code>sketch</code>). - **Purpose**: Specify the main directory for downloaded files.  
<ul> - **Usage**: Type the path or click "Browse..." to select a folder.  
<li><strong>Scope: [Type] Button</strong>: This button changes how the skip filter works: - **Requirement**: Mandatory for all download operations.
<ul>
<li><strong>Posts</strong>: Skips the entire post if a keyword is found in the title.</li> ### 1.2. Action Buttons
<li><strong>Files</strong>: Skips only individual files if a keyword is found in the filename.</li> **⬇️ Start Download / 🔗 Extract Links Button**  
<li><strong>Both</strong>: Applies both levels of skipping.</li> - **Purpose**: Initiates downloading or link extraction.  
</ul> - **Behavior**:  
</li>   - Shows "🔗 Extract Links" if "Only Links" is selected.  
</ul>   - Otherwise, shows "⬇️ Start Download".  
</li>   - Supports single-threaded or multi-threaded downloads based on settings.
<li><strong>Remove Words from name</strong>: Automatically cleans downloaded filenames by removing any specified words (e.g., "patreon," "HD").</li>
</ul> **🔄 Restore Download Button**  
<h3><strong>File Type Filter (Radio Buttons)</strong></h3> - **Visibility**: Appears if an incomplete session is detected on startup.  
<p>This section lets you choose the kind of content you want:</p> - **Purpose**: Resumes a previously interrupted download session.
<ul>
<li><strong>All, Images/GIFs, Videos, 🎧 Only Audio, 📦 Only Archives</strong>: These options filter the downloads to only include the selected file types.</li> **⏸️ Pause / ▶️ Resume Download Button**  
<li><strong>🔗 Only Links</strong>: This special mode doesn't download any files. Instead, it scans post descriptions and lists all external links (like Mega, Google Drive) in the log panel.</li> - **Purpose**: Pause or resume the ongoing download.  
<li><strong>More</strong>: Opens a dialog for text-only downloads. You can choose to save post <strong>descriptions</strong> or <strong>comments</strong> as formatted <strong>PDF, DOCX, or TXT</strong> files. A key feature here is the <strong>"Single PDF"</strong> option, which compiles the text from all downloaded posts into one continuous, sorted PDF document.</li> - **Behavior**: Toggles between "Pause" and "Resume". Some UI settings can be changed while paused.
</ul>
<hr> **❌ Cancel & Reset UI Button**  
<h2><strong>Download Options & Advanced Settings (Checkboxes)</strong></h2> - **Purpose**: Stops the current operation and performs a "soft" reset.  
<ul> - **Behavior**: Halts background threads, preserves URL and Download Location inputs, resets other settings.
<li><strong>Skip .zip</strong>: A simple toggle to ignore archive files during downloads.</li>
<li><strong>Download Thumbnails Only</strong>: Downloads only the small preview images instead of the full-resolution files.</li> **🔄 Reset Button (in the log area)**  
<li><strong>Scan Content for Images</strong>: A crucial feature that scans the post's text content for embedded images that may not be listed in the API, ensuring a more complete download.</li> - **Purpose**: Performs a "hard" reset when no operation is active.  
<li><strong>Compress to WebP</strong>: Saves disk space by automatically converting large images into the efficient WebP format.</li> - **Behavior**: Clears all inputs, resets options to default, and clears logs.
<li><strong>Keep Duplicates</strong>: Opens a dialog to control how files with identical content are handled. The default is to skip duplicates, but you can choose to keep all of them or set a specific limit (e.g., "keep up to 2 copies of the same file").</li>
<li><strong>Subfolder per Post</strong>: Organizes downloads by creating a unique folder for each post, named after the post's title.</li> ## 2. Filtering & Content Selection
<li><strong>Date Prefix</strong>: When "Subfolder per Post" is on, this adds the post's date to the beginning of the folder name (e.g., <code>2025-07-25 Post Title</code>).</li> These options allow precise control over downloaded content.
<li><strong>Separate Folders by Known.txt</strong>: This enables the automatic folder organization system based on your "Known Names" list.</li>
<li><strong>Use Cookie</strong>: Allows the application to use browser cookies to access content that might be behind a paywall or login. You can paste a cookie string directly or use <strong>Browse...</strong> to select a <code>cookies.txt</code> file.</li> ### 2.1. Content Filtering
<li><strong>Use Multithreading</strong>: Greatly speeds up downloads of creator feeds by processing multiple posts at once. The number of <strong>Threads</strong> can be configured.</li> **🎯 Filter by Character(s) Input Field**  
<li><strong>Show External Links in Log</strong>: When checked, a secondary log panel appears at the bottom of the right side, dedicated to listing any external links found.</li> - **Purpose**: Download content related to specific characters or series.  
</ul> - **Usage**: Enter comma-separated character names.  
<hr> - **Advanced Syntax**:  
<h2><strong>Known Names Management (Bottom-Left)</strong></h2>   - `Nami`: Simple filter.  
<p>This powerful feature automates the creation of organized, named folders.</p>   - `(Vivi, Ulti)`: Grouped filter. Matches posts with "Vivi" OR "Ulti". Creates a shared folder like `Vivi Ulti` if subfolders are enabled.  
<ul>   - `(Boa, Hancock)~`: Aliased filter. Treats "Boa" and "Hancock" as the same entity.
<li><strong>Known Shows/Characters List</strong>: Displays all the names and groups you've saved.</li>
<li><strong>Search...</strong>: Filters the list to quickly find a name.</li> **Filter: [Type] Button (Character Filter Scope)**  
<li><strong>Open Known.txt</strong>: Opens the source file in a text editor for advanced manual editing.</li> - **Purpose**: Defines where the character filter is applied. Cycles on click.  
<li><strong>Add New Name</strong>: - **Options**:  
<ul>   - **Filter: Title** (Default): Matches post titles.  
<li><strong>Single Name</strong>: Typing <code>Tifa Lockhart</code> and clicking <strong> Add</strong> creates an entry that will match "Tifa Lockhart".</li>   - **Filter: Files**: Matches filenames.  
<li><strong>Group</strong>: Typing <code>(Boa, Hancock, Snake Princess)~</code> and clicking <strong> Add</strong> creates a single entry named "Boa Hancock Snake Princess". The application will then look for "Boa," "Hancock," OR "Snake Princess" in titles/filenames and save any matches into that combined folder.</li>   - **Filter: Both**: Checks title first, then filenames.  
</ul>   - **Filter: Comments (Beta)**: Checks filenames, then post comments.
</li>
<li><strong>⤵️ Add to Filter</strong>: Opens a dialog with your full Known Names list, allowing you to check multiple entries and add them all to the "Filter by Character(s)" field at once.</li> **🚫 Skip with Words Input Field**  
<li><strong>🗑️ Delete Selected</strong>: Removes highlighted names from your list.</li> - **Purpose**: Exclude posts/files with specified keywords (e.g., `WIP`, `sketch`).
</ul>
<hr> **Scope: [Type] Button (Skip Words Scope)**  
<h2><strong>Action Buttons & Status Controls</strong></h2> - **Purpose**: Defines where skip words are applied. Cycles on click.  
<ul> - **Options**:  
<li><strong>⬇️ Start Download / 🔗 Extract Links</strong>: The main action button. Its function is dynamic:   - **Scope: Posts** (Default): Skips posts if the title contains a skip word.  
<ul>   - **Scope: Files**: Skips files if the filename contains a skip word.  
<li><strong>Normal Mode</strong>: Starts the download based on the current settings.</li>   - **Scope: Both**: Applies both rules.
<li><strong>Update Mode</strong>: After selecting a creator profile, this button changes to <strong>🔄 Check for Updates</strong>.</li>
<li><strong>Update Confirmation</strong>: After new posts are found, it changes to <strong>⬇️ Start Download (X new)</strong>.</li> **✂️ Remove Words from Name Input Field**  
<li><strong>Link Extraction Mode</strong>: The text changes to <strong>🔗 Extract Links</strong>.</li> - **Purpose**: Remove unwanted text from filenames (e.g., `patreon`, `[HD]`).
</ul>
</li> ### 2.2. File Type Filtering
<li><strong>⏸️ Pause / ▶️ Resume Download</strong>: Pauses the ongoing download, allowing you to change certain settings (like filters) on the fly. Click again to resume.</li> **Filter Files (Radio Buttons)**  
<li><strong>❌ Cancel & Reset UI</strong>: Immediately stops all download activity and resets the UI to a clean state, preserving your URL and Download Location inputs.</li> - **Purpose**: Select file types to download.  
<li><strong>Error Button</strong>: If files fail to download, they are logged. This button opens a dialog listing all failed files and will show a count of errors (e.g., <strong>(5) Error</strong>). From the dialog, you can: - **Options**:  
<ul>   - **All**: All file types.  
<li>Select specific files to <strong>Retry</strong> downloading.</li>   - **Images/GIFs**: Common image formats.  
<li><strong>Export</strong> the list of failed URLs to a <code>.txt</code> file.</li>   - **Videos**: Common video formats.  
</ul>   - **🎧 Only Audio**: Common audio formats.  
</li>   - **📦 Only Archives**: Only `.zip` and `.rar` files.  
<li><strong>🔄 Reset (Top-Right)</strong>: A hard reset that clears all logs and returns every single UI element to its default state.</li>   - **🔗 Only Links**: Extracts external links without downloading files.
<li><strong>⚙️ (Settings)</strong>: Opens the main Settings dialog.</li>
<li><strong>📜 (History)</strong>: Opens the Download History dialog.</li> **Skip .zip / Skip .rar Checkboxes**  
<li><strong>? (Help)</strong>: Opens a helpful guide explaining the application's features.</li> - **Purpose**: Skip downloading `.zip` or `.rar` files.  
<li><strong>❤️ Support</strong>: Opens a dialog with information on how to support the developer.</li> - **Behavior**: Disabled when "📦 Only Archives" is active.
</ul>
<hr> ## 3. Download Customization
<h2><strong>Specialized Modes & Features</strong></h2> Options to refine the download process and output.
<h3><strong>⭐ Favorite Mode</strong></h3>
<p>Activating this mode transforms the UI for managing saved collections:</p> - **Download Thumbnails Only**: Downloads small preview images instead of full-resolution files.  
<ul> - **Scan Content for Images**: Scans post HTML for `<img>` tags, crucial for images in descriptions.  
<li>The URL input is disabled.</li> - **Compress to WebP**: Converts images to WebP format (requires Pillow library).
<li>The main action buttons are replaced with: - **Keep Duplicates**: Normally, if a post contains multiple files with the same name, only the first is downloaded. Checking this option will download all of them, renaming subsequent unique files with a numeric suffix (e.g., `image_1.jpg`).
<ul> - **🗄️ Custom Folder Name (Single Post Only)**: Specify a custom folder name for a single post's content (appears if subfolders are enabled).
<li><strong>🖼️ Favorite Artists</strong>: Opens a dialog to browse and queue downloads from your saved favorite creators.</li>
<li><strong>📄 Favorite Posts</strong>: Opens a dialog to browse and queue downloads for specific saved favorite posts.</li> ## 4. 📖 Manga/Comic Mode
</ul> A mode for downloading creator feeds in chronological order, ideal for sequential content.
</li>
<li><strong>Scope: [Location] Button</strong>: Toggles where the favorited content is saved: - **Activation**: Active when downloading a creator's entire feed (not a single post).  
<ul> - **Core Behavior**: Fetches all posts, processing from oldest to newest.  
<li><strong>Selected Location</strong>: Saves all content directly into the main "Download Location".</li> - **Filename Style Toggle Button (in the log area)**:  
<li><strong>Artist Folders</strong>: Creates a subfolder for each artist inside the main "Download Location".</li>   - **Purpose**: Controls file naming in Manga Mode. Cycles on click.  
</ul>   - **Options**:  
</li>     - **Name: Post Title**: First file named after post title; others keep original names.  
</ul>     - **Name: Original File**: Files keep server-provided names, with optional prefix.  
<h3><strong>📖 Manga/Comic Mode</strong></h3>     - **Name: Title+G.Num**: Global numbering with post title prefix (e.g., `Chapter 1_001.jpg`).  
<p>This mode is designed for sequential content and has several effects:</p>     - **Name: Date Based**: Sequential naming by post date (e.g., `001.jpg`), with optional prefix.  
<ul>     - **Name: Post ID**: Files named after post ID to avoid clashes.  
<li><strong>Reverses Download Order</strong>: It fetches and downloads posts from <strong>oldest to newest</strong>.</li>     - **Name: Date + Title**: Combines post date and title for filenames.
<li><strong>Enables Special Naming</strong>: A <strong><code>Name: [Style]</code></strong> button appears, allowing you to choose how files are named to maintain their correct order (e.g., by Post Title, by Date, or simple sequential numbering like <code>001, 002, 003...</code>).</li>
<li><strong>Disables Multithreading (for certain styles)</strong>: To guarantee perfect sequential numbering, multithreading for posts is automatically disabled for certain naming styles.</li> ## 5. Folder Organization & Known.txt
</ul> Controls for structuring downloaded content.
<h3><strong>Session & Error Management</strong></h3>
<ul> - **Separate Folders by Name/Title Checkbox**: Enables automatic subfolder creation.  
<li><strong>Session Restore</strong>: If the application is closed unexpectedly during a download, it will detect the incomplete session on the next launch. The UI will present a <strong>🔄 Restore Download</strong> button to resume exactly where you left off. You can also choose to discard the session.</li> - **Subfolder per Post Checkbox**: Creates subfolders for each post, named after the post title.  
<li><strong>Update Checking</strong>: By selecting a creator profile via the <strong>🎨 Creator Selection Popup</strong>, you can run an update check. The application compares the posts on the server with your download history for that creator and will prompt you to download only the new content.</li> - **Date Prefix for Post Subfolders Checkbox**: When used with "Subfolder per Post," this option prefixes the folder name with the post's upload date (e.g., `2025-07-11 Post Title`), allowing for chronological sorting.
</ul> - **Known.txt Management UI (Bottom Left)**:  
<h3><strong>Logging & Monitoring</strong></h3>   - **Purpose**: Manages a local `Known.txt` file for series, characters, or terms used in folder creation.  
<ul>   - **List Display**: Shows primary names from `Known.txt`.  
<li><strong>Progress Log</strong>: The main log provides real-time feedback on the download process, including status messages, file saves, skips, and errors.</li>   - ** Add Button**: Adds names or groups (e.g., `(Character A, Alias B)~`).  
<li><strong>👁️ Log View Toggle</strong>: Switches the log view between the standard <strong>Progress Log</strong> and a <strong>Missed Character Log</strong>, which shows potential character names from posts that were skipped by your filters, helping you discover new names to add to your list.</li>   - **⤵️ Add to Filter Button**: Select names from `Known.txt` for the character filter.  
</ul>   - **🗑️ Delete Selected Button**: Removes selected names from `Known.txt`.  
</div>   - **Open Known.txt Button**: Opens the file in the default text editor.  
  - **❓ Help Button**: Opens this feature guide.  
  - **📜 History Button**: Views recent download history.
## 6. ⭐ Favorite Mode (Kemono.su Only)
Download from favorited artists/posts on Kemono.su.
- **Enable Checkbox ("⭐ Favorite Mode")**:  
  - Switches to Favorite Mode.  
  - Disables the main URL input.  
  - Changes action buttons to "Favorite Artists" and "Favorite Posts".  
  - Requires cookies.  
- **🖼️ Favorite Artists Button**: Select and download from favorited artists.  
- **📄 Favorite Posts Button**: Select and download specific favorited posts.  
- **Favorite Download Scope Button**:  
  - **Scope: Selected Location**: Downloads favorites to the main directory.  
  - **Scope: Artist Folders**: Creates subfolders per artist.
## 7. Advanced Settings & Performance
- **🍪 Cookie Management**:  
  - **Use Cookie Checkbox**: Enables cookies for restricted content.  
  - **Cookie Text Field**: Paste cookie string.  
  - **Browse... Button**: Select a `cookies.txt` file (Netscape format).  
- **Use Multithreading Checkbox & Threads Input**:  
  - **Purpose**: Configures simultaneous operations.  
  - **Behavior**: Sets concurrent post processing (creator feeds) or file downloads (single posts).  
- **Multi-part Download Toggle Button**:  
  - **Purpose**: Enables/disables multi-segment downloading for large files.  
  - **Note**: Best for large files; less efficient for small files.
## 8. Logging, Monitoring & Error Handling
- **📜 Progress Log Area**: Displays messages, progress, and errors.  
- **👁️ / 🙈 Log View Toggle Button**: Switches between Progress Log and Missed Character Log (skipped posts).  
- **Show External Links in Log**: Displays external links (e.g., Mega, Google Drive) in a secondary panel.  
- **Export Links Button**: Saves extracted links to a `.txt` file in "Only Links" mode.  
- **Download Extracted Links Button**: Downloads files from supported external links in "Only Links" mode.  
- **🆘 Error Button & Dialog**:  
  - **Purpose**: Active if files fail to download. The button will display a live count of failed files (e.g., **(3) Error**).  
  - **Dialog Features**:  
    - Lists failed files.  
    - Retry failed downloads.  
    - Export failed URLs to a text file.
## 9. Application Settings (⚙️)
- **Appearance**: Switch between Light and Dark themes.  
- **Language**: Change UI language (restart required).

View File

@@ -159,7 +159,7 @@ class DownloadManager:
if self.thread_pool: if self.thread_pool:
self.thread_pool.shutdown(wait=True) self.thread_pool.shutdown(wait=True)
self.is_running = False self.is_running = False
self._log("🏁 All processing tasks have completed or been cancelled.") self._log("🏁 All processing tasks have completed.")
self.progress_queue.put({ self.progress_queue.put({
'type': 'finished', 'type': 'finished',
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames) 'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)

View File

@@ -594,33 +594,6 @@ class PostProcessorWorker:
except OSError: pass except OSError: pass
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
if (self.compress_images and downloaded_part_file_path and
is_image(api_original_filename) and
os.path.getsize(downloaded_part_file_path) > 1.5 * 1024 * 1024):
self.logger(f" 🔄 Compressing '{api_original_filename}' to WebP...")
try:
with Image.open(downloaded_part_file_path) as img:
# Convert to RGB to avoid issues with paletted images or alpha channels in WebP
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGBA')
# Use an in-memory buffer to save the compressed image
output_buffer = BytesIO()
img.save(output_buffer, format='WebP', quality=85)
# This buffer now holds the compressed data
data_to_write_io = output_buffer
# Update the filename to use the .webp extension
base, _ = os.path.splitext(filename_to_save_in_main_path)
filename_to_save_in_main_path = f"{base}.webp"
self.logger(f" ✅ Compression successful. New size: {len(data_to_write_io.getvalue()) / (1024*1024):.2f} MB")
except Exception as e_compress:
self.logger(f" ⚠️ Failed to compress '{api_original_filename}': {e_compress}. Saving original file instead.")
data_to_write_io = None # Ensure we fall back to saving the original
effective_save_folder = target_folder_path effective_save_folder = target_folder_path
base_name, extension = os.path.splitext(filename_to_save_in_main_path) base_name, extension = os.path.splitext(filename_to_save_in_main_path)
counter = 1 counter = 1
@@ -637,17 +610,15 @@ class PostProcessorWorker:
try: try:
if data_to_write_io: if data_to_write_io:
# Write the compressed data from the in-memory buffer
with open(final_save_path, 'wb') as f_out: with open(final_save_path, 'wb') as f_out:
time.sleep(0.05)
f_out.write(data_to_write_io.getvalue()) f_out.write(data_to_write_io.getvalue())
# Clean up the original downloaded part file
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
try: try:
os.remove(downloaded_part_file_path) os.remove(downloaded_part_file_path)
except OSError as e_rem: except OSError as e_rem:
self.logger(f" -> Failed to remove .part after compression: {e_rem}") self.logger(f" -> Failed to remove .part after compression: {e_rem}")
else: else:
# No compression was done, just rename the original file
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path): if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
time.sleep(0.1) time.sleep(0.1)
os.rename(downloaded_part_file_path, final_save_path) os.rename(downloaded_part_file_path, final_save_path)
@@ -720,6 +691,7 @@ class PostProcessorWorker:
else: else:
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER, details_for_failure
def process(self): def process(self):
result_tuple = (0, 0, [], [], [], None, None) result_tuple = (0, 0, [], [], [], None, None)
@@ -887,6 +859,17 @@ class PostProcessorWorker:
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None) result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple return result_tuple
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
if self._check_pause(f"Skip words (post title) for post {post_id}"):
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple
post_title_lower = post_title.lower()
for skip_word in self.skip_words_list:
if skip_word.lower() in post_title_lower:
self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'. Scope: {self.skip_words_scope}")
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple
if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match: if not self.extract_links_only and self.manga_mode_active and current_character_filters and (self.char_filter_scope == CHAR_SCOPE_TITLE or self.char_filter_scope == CHAR_SCOPE_BOTH) and not post_is_candidate_by_title_char_match:
self.logger(f" -> Skip Post (Manga Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.") self.logger(f" -> Skip Post (Manga Mode with Title/Both Scope - No Title Char Match): Title '{post_title[:50]}' doesn't match filters.")
self._emit_signal('missed_character_post', post_title, "Manga Mode: No title match for character filter (Title/Both scope)") self._emit_signal('missed_character_post', post_title, "Manga Mode: No title match for character filter (Title/Both scope)")
@@ -897,7 +880,6 @@ class PostProcessorWorker:
self.logger(f"⚠️ Corrupt attachment data for post {post_id} (expected list, got {type(post_attachments)}). Skipping attachments.") self.logger(f"⚠️ Corrupt attachment data for post {post_id} (expected list, got {type(post_attachments)}). Skipping attachments.")
post_attachments = [] post_attachments = []
# CORRECTED LOGIC: Determine folder path BEFORE skip checks
base_folder_names_for_post_content = [] base_folder_names_for_post_content = []
determined_post_save_path_for_history = self.override_output_dir if self.override_output_dir else self.download_root determined_post_save_path_for_history = self.override_output_dir if self.override_output_dir else self.download_root
if not self.extract_links_only and self.use_subfolders: if not self.extract_links_only and self.use_subfolders:
@@ -1046,28 +1028,6 @@ class PostProcessorWorker:
break 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(base_path_for_post_subfolder, final_post_subfolder_name)
if self.skip_words_list and (self.skip_words_scope == SKIP_SCOPE_POSTS or self.skip_words_scope == SKIP_SCOPE_BOTH):
if self._check_pause(f"Skip words (post title) for post {post_id}"):
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
return result_tuple
post_title_lower = post_title.lower()
for skip_word in self.skip_words_list:
if skip_word.lower() in post_title_lower:
self.logger(f" -> Skip Post (Keyword in Title '{skip_word}'): '{post_title[:50]}...'. Scope: {self.skip_words_scope}")
# Create a history object for the skipped post to record its ID
history_data_for_skipped_post = {
'post_id': post_id,
'service': self.service,
'user_id': self.user_id,
'post_title': post_title,
'top_file_name': "N/A (Post Skipped)",
'num_files': num_potential_files_in_post,
'upload_date_str': post_data.get('published') or post_data.get('added') or "Unknown",
'download_location': determined_post_save_path_for_history
}
result_tuple = (0, num_potential_files_in_post, [], [], [], history_data_for_skipped_post, None)
return result_tuple
if self.filter_mode == 'text_only' and not self.extract_links_only: if self.filter_mode == 'text_only' and not self.extract_links_only:
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})") self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
post_title_lower = post_title.lower() post_title_lower = post_title.lower()

View File

@@ -969,9 +969,6 @@ class EmptyPopupDialog (QDialog ):
self .parent_app .link_input .setPlaceholderText ( self .parent_app .link_input .setPlaceholderText (
self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue ) self .parent_app ._tr ("items_in_queue_placeholder","{count} items in queue from popup.").format (count =total_in_queue )
) )
self.selected_creators_for_queue.clear()
self .accept () self .accept ()
else : else :
QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"), QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),

View File

@@ -23,11 +23,6 @@ from PyQt5.QtWidgets import (
QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame, QScrollArea, QListWidgetItem, QSizePolicy, QProgressBar, QAbstractItemView, QFrame,
QMainWindow, QAction, QGridLayout, QMainWindow, QAction, QGridLayout,
) )
try:
from PIL import Image
except ImportError:
Image = None
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker, QCoreApplication from PyQt5.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer, QSettings, QStandardPaths, QUrl, QSize, QProcess, QMutex, QMutexLocker, QCoreApplication
from ..services.drive_downloader import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file from ..services.drive_downloader import download_mega_file as drive_download_mega_file ,download_gdrive_file ,download_dropbox_file
from ..core.workers import DownloadThread as BackendDownloadThread from ..core.workers import DownloadThread as BackendDownloadThread
@@ -233,7 +228,6 @@ class DownloaderApp (QWidget ):
self.downloaded_hash_counts = defaultdict(int) self.downloaded_hash_counts = defaultdict(int)
self.downloaded_hash_counts_lock = threading.Lock() self.downloaded_hash_counts_lock = threading.Lock()
self.session_temp_files = [] self.session_temp_files = []
self.single_pdf_mode = False
self.save_creator_json_enabled_this_session = True self.save_creator_json_enabled_this_session = True
print(f" Known.txt will be loaded/saved at: {self.config_file}") print(f" Known.txt will be loaded/saved at: {self.config_file}")
@@ -1430,21 +1424,15 @@ class DownloaderApp (QWidget ):
def _check_if_all_work_is_done(self): def _check_if_all_work_is_done(self):
""" """
Checks if the fetcher thread is done AND if all submitted tasks have been processed OR if a cancellation was requested. Checks if the fetcher thread is done AND if all submitted tasks have been processed.
If so, finalizes the download. This is the central point for completion logic. If so, finalizes the download.
""" """
fetcher_is_done = not self.is_fetcher_thread_running fetcher_is_done = not self.is_fetcher_thread_running
all_workers_are_done = (self.processed_posts_count >= self.total_posts_to_process) all_workers_are_done = (self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process)
is_cancelled = self.cancellation_event.is_set()
if fetcher_is_done and (all_workers_are_done or is_cancelled): if fetcher_is_done and all_workers_are_done:
if not self.is_finishing: self.log_signal.emit("🏁 All fetcher and worker tasks complete.")
if is_cancelled: self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames)
self.log_signal.emit("🏁 Fetcher cancelled. Finalizing...")
else:
self.log_signal.emit("🏁 All fetcher and worker tasks complete. Finalizing...")
self.finished_signal.emit(self.download_counter, self.skip_counter, is_cancelled, self.all_kept_original_filenames)
def _sync_queue_with_link_input (self ,current_text ): def _sync_queue_with_link_input (self ,current_text ):
""" """
@@ -4167,34 +4155,49 @@ class DownloaderApp (QWidget ):
self ._update_log_display_mode_button_text () self ._update_log_display_mode_button_text ()
self ._filter_links_log () self ._filter_links_log ()
def cancel_download_button_action(self): def cancel_download_button_action (self ):
""" self.is_finishing = True
Signals all active download processes to cancel but DOES NOT reset the UI. 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
The UI reset is now handled by the 'download_finished' method. self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...")
""" self._cleanup_temp_files()
if self.cancellation_event.is_set(): self._clear_session_file() # Clear session file on explicit cancel
self.log_signal.emit(" Cancellation is already in progress.") if self .external_link_download_thread and self .external_link_download_thread .isRunning ():
return self .log_signal .emit (" Cancelling active External Link download thread...")
self .external_link_download_thread .cancel ()
self.log_signal.emit("⚠️ Requesting cancellation of download process...") current_url =self .link_input .text ()
self.cancellation_event.set() current_dir =self .dir_input .text ()
# Update UI to "Cancelling" state self .cancellation_event .set ()
self.pause_btn.setEnabled(False) self .is_fetcher_thread_running =False
self.cancel_btn.setEnabled(False) if self .download_thread and self .download_thread .isRunning ():self .download_thread .requestInterruption ();self .log_signal .emit (" Signaled single download thread to interrupt.")
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait.")) if self .thread_pool :
self .log_signal .emit (" Initiating non-blocking shutdown and cancellation of worker pool tasks...")
self .thread_pool .shutdown (wait =False ,cancel_futures =True )
self .thread_pool =None
self .active_futures =[]
# Signal all active components to stop self .external_link_queue .clear ();self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
if self.download_thread and self.download_thread.isRunning():
self.download_thread.requestInterruption()
self.log_signal.emit(" Signaled single download thread to interrupt.")
if self.thread_pool: self ._perform_soft_ui_reset (preserve_url =current_url ,preserve_dir =current_dir )
self.log_signal.emit(" Signaling worker pool to cancel futures...")
if self.external_link_download_thread and self.external_link_download_thread.isRunning(): self .progress_label .setText (f"{self ._tr ('status_cancelled_by_user','Cancelled by user')}. {self ._tr ('ready_for_new_task_text','Ready for new task.')}")
self.log_signal.emit(" Cancelling active External Link download thread...") self .file_progress_label .setText ("")
self.external_link_download_thread.cancel() if self .pause_event :self .pause_event .clear ()
self .log_signal .emit (" UI reset. Ready for new operation. Background tasks are being terminated.")
self .is_paused =False
if hasattr (self ,'retryable_failed_files_info')and self .retryable_failed_files_info :
self .log_signal .emit (f" Discarding {len (self .retryable_failed_files_info )} pending retryable file(s) due to cancellation.")
self .cancellation_message_logged_this_session =False
self .retryable_failed_files_info .clear ()
self .favorite_download_queue .clear ()
self .permanently_failed_files_for_dialog .clear ()
self .is_processing_favorites_queue =False
self .favorite_download_scope =FAVORITE_SCOPE_SELECTED_LOCATION
self ._update_favorite_scope_button_text ()
if hasattr (self ,'link_input'):
self .last_link_input_text_for_queue_sync =self .link_input .text ()
self .cancellation_message_logged_this_session =False
def _get_domain_for_service (self ,service_name :str )->str : def _get_domain_for_service (self ,service_name :str )->str :
"""Determines the base domain for a given service.""" """Determines the base domain for a given service."""
@@ -4212,129 +4215,119 @@ class DownloaderApp (QWidget ):
return return
self.is_finishing = True self.is_finishing = True
try: self.log_signal.emit("🏁 Download of current item complete.")
if cancelled_by_user:
self.log_signal.emit("✅ Cancellation complete. Resetting UI.")
current_url = self.link_input.text()
current_dir = self.dir_input.text()
self._perform_soft_ui_reset(preserve_url=current_url, preserve_dir=current_dir)
self.progress_label.setText(f"{self._tr('status_cancelled_by_user', 'Cancelled by user')}. {self._tr('ready_for_new_task_text', 'Ready for new task.')}")
self.file_progress_label.setText("")
if self.pause_event: self.pause_event.clear()
self.is_paused = False
return # Exit after handling cancellation
self.log_signal.emit("🏁 Download of current item complete.") if self.is_processing_favorites_queue and self.favorite_download_queue:
self.log_signal.emit("✅ Item finished. Processing next in queue...")
self._process_next_favorite_download()
return
if self.is_processing_favorites_queue and self.favorite_download_queue: if self.is_processing_favorites_queue:
self.log_signal.emit("✅ Item finished. Processing next in queue...") self.is_processing_favorites_queue = False
self.is_finishing = False # Allow the next item in queue to start self.log_signal.emit("✅ All items from the download queue have been processed.")
self._process_next_favorite_download()
return
if self.is_processing_favorites_queue: if not cancelled_by_user and not self.retryable_failed_files_info:
self.is_processing_favorites_queue = False self._clear_session_file()
self.log_signal.emit("✅ All items from the download queue have been processed.") self.interrupted_session_data = None
self.is_restore_pending = False
if not cancelled_by_user and not self.retryable_failed_files_info: self._finalize_download_history()
self._clear_session_file() status_message = self._tr("status_cancelled_by_user", "Cancelled by user") if cancelled_by_user else self._tr("status_completed", "Completed")
self.interrupted_session_data = None if cancelled_by_user and self.retryable_failed_files_info:
self.is_restore_pending = False self.log_signal.emit(f" Download cancelled, discarding {len(self.retryable_failed_files_info)} file(s) that were pending retry.")
self.retryable_failed_files_info.clear()
self._finalize_download_history() summary_log = "=" * 40
status_message = self._tr("status_completed", "Completed") summary_log += f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n"
summary_log += "=" * 40
self.log_signal.emit(summary_log)
self.log_signal.emit("")
summary_log = "=" * 40 if self.thread_pool:
summary_log += f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n" self.log_signal.emit(" Shutting down worker thread pool...")
summary_log += "=" * 40 self.thread_pool.shutdown(wait=False)
self.log_signal.emit(summary_log) self.thread_pool = None
self.log_signal.emit("") self.log_signal.emit(" Thread pool shut down.")
if self.thread_pool: if self.single_pdf_setting and self.session_temp_files and not cancelled_by_user:
self.thread_pool.shutdown(wait=False) try:
self.thread_pool = None self._trigger_single_pdf_creation()
finally:
if self.single_pdf_setting and self.session_temp_files:
try:
self._trigger_single_pdf_creation()
finally:
self._cleanup_temp_files()
else:
self._cleanup_temp_files() self._cleanup_temp_files()
self.single_pdf_setting = False self.single_pdf_setting = False
else:
self._cleanup_temp_files()
self.single_pdf_setting = False
if kept_original_names_list is None: if kept_original_names_list is None:
kept_original_names_list = list(self.all_kept_original_filenames) if hasattr(self, 'all_kept_original_filenames') else [] kept_original_names_list = list(self.all_kept_original_filenames) if hasattr(self, 'all_kept_original_filenames') else []
if kept_original_names_list is None: if kept_original_names_list is None:
kept_original_names_list = [] kept_original_names_list = []
if kept_original_names_list: if kept_original_names_list:
intro_msg = ( intro_msg = (
HTML_PREFIX + HTML_PREFIX +
"<p> The following files from multi-file manga posts " "<p> The following files from multi-file manga posts "
"(after the first file) kept their <b>original names</b>:</p>" "(after the first file) kept their <b>original names</b>:</p>"
)
self.log_signal.emit(intro_msg)
html_list_items = "<ul>"
for name in kept_original_names_list:
html_list_items += f"<li><b>{name}</b></li>"
html_list_items += "</ul>"
self.log_signal.emit(HTML_PREFIX + html_list_items)
self.log_signal.emit("=" * 40)
if self.download_thread:
try:
if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log)
if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal)
if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished)
if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result)
if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.disconnect(self.handle_external_link_signal)
if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.disconnect(self.update_file_progress_display)
if hasattr(self.download_thread, 'missed_character_post_signal'): self.download_thread.missed_character_post_signal.disconnect(self.handle_missed_character_post)
if hasattr(self.download_thread, 'retryable_file_failed_signal'): self.download_thread.retryable_file_failed_signal.disconnect(self._handle_retryable_file_failure)
if hasattr(self.download_thread, 'file_successfully_downloaded_signal'): self.download_thread.file_successfully_downloaded_signal.disconnect(self._handle_actual_file_downloaded)
if hasattr(self.download_thread, 'post_processed_for_history_signal'): self.download_thread.post_processed_for_history_signal.disconnect(self._add_to_history_candidates)
except (TypeError, RuntimeError) as e:
self.log_signal.emit(f" Note during single-thread signal disconnection: {e}")
if not self.download_thread.isRunning():
if self.download_thread:
self.download_thread.deleteLater()
self.download_thread = None
self.progress_label.setText(
f"{status_message}: "
f"{total_downloaded} {self._tr('files_downloaded_label', 'downloaded')}, "
f"{total_skipped} {self._tr('files_skipped_label', 'skipped')}."
) )
self.file_progress_label.setText("") self.log_signal.emit(intro_msg)
html_list_items = "<ul>"
for name in kept_original_names_list:
html_list_items += f"<li><b>{name}</b></li>"
html_list_items += "</ul>"
self.log_signal.emit(HTML_PREFIX + html_list_items)
self.log_signal.emit("=" * 40)
if not cancelled_by_user and self.retryable_failed_files_info: if self.download_thread:
num_failed = len(self.retryable_failed_files_info) try:
reply = QMessageBox.question(self, "Retry Failed Downloads?", if hasattr(self.download_thread, 'progress_signal'): self.download_thread.progress_signal.disconnect(self.handle_main_log)
f"{num_failed} file(s) failed with potentially recoverable errors (e.g., IncompleteRead).\n\n" if hasattr(self.download_thread, 'add_character_prompt_signal'): self.download_thread.add_character_prompt_signal.disconnect(self.add_character_prompt_signal)
"Would you like to attempt to download these failed files again?", if hasattr(self.download_thread, 'finished_signal'): self.download_thread.finished_signal.disconnect(self.download_finished)
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) if hasattr(self.download_thread, 'receive_add_character_result'): self.character_prompt_response_signal.disconnect(self.download_thread.receive_add_character_result)
if reply == QMessageBox.Yes: if hasattr(self.download_thread, 'external_link_signal'): self.download_thread.external_link_signal.disconnect(self.handle_external_link_signal)
self.is_finishing = False # Allow retry session to start if hasattr(self.download_thread, 'file_progress_signal'): self.download_thread.file_progress_signal.disconnect(self.update_file_progress_display)
self._start_failed_files_retry_session() if hasattr(self.download_thread, 'missed_character_post_signal'): self.download_thread.missed_character_post_signal.disconnect(self.handle_missed_character_post)
return # Exit to allow retry session to run if hasattr(self.download_thread, 'retryable_file_failed_signal'): self.download_thread.retryable_file_failed_signal.disconnect(self._handle_retryable_file_failure)
else: if hasattr(self.download_thread, 'file_successfully_downloaded_signal'): self.download_thread.file_successfully_downloaded_signal.disconnect(self._handle_actual_file_downloaded)
self.log_signal.emit(" User chose not to retry failed files.") if hasattr(self.download_thread, 'post_processed_for_history_signal'): self.download_thread.post_processed_for_history_signal.disconnect(self._add_to_history_candidates)
self.permanently_failed_files_for_dialog.extend(self.retryable_failed_files_info) except (TypeError, RuntimeError) as e:
if self.permanently_failed_files_for_dialog: self.log_signal.emit(f" Note during single-thread signal disconnection: {e}")
self.log_signal.emit(f"🆘 Error button enabled. {len(self.permanently_failed_files_for_dialog)} file(s) can be viewed.")
self.cancellation_message_logged_this_session = False
self.retryable_failed_files_info.clear()
self.is_fetcher_thread_running = False if not self.download_thread.isRunning():
if self.download_thread:
self.download_thread.deleteLater()
self.download_thread = None
self.set_ui_enabled(True) self.progress_label.setText(
self._update_button_states_and_connections() f"{status_message}: "
self.cancellation_message_logged_this_session = False f"{total_downloaded} {self._tr('files_downloaded_label', 'downloaded')}, "
self.active_update_profile = None f"{total_skipped} {self._tr('files_skipped_label', 'skipped')}."
finally: )
self.is_finishing = False self.file_progress_label.setText("")
if not cancelled_by_user and self.retryable_failed_files_info:
num_failed = len(self.retryable_failed_files_info)
reply = QMessageBox.question(self, "Retry Failed Downloads?",
f"{num_failed} file(s) failed with potentially recoverable errors (e.g., IncompleteRead).\n\n"
"Would you like to attempt to download these failed files again?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes:
self._start_failed_files_retry_session()
return
else:
self.log_signal.emit(" User chose not to retry failed files.")
self.permanently_failed_files_for_dialog.extend(self.retryable_failed_files_info)
if self.permanently_failed_files_for_dialog:
self.log_signal.emit(f"🆘 Error button enabled. {len(self.permanently_failed_files_for_dialog)} file(s) can be viewed.")
self.cancellation_message_logged_this_session = False
self.retryable_failed_files_info.clear()
self.is_fetcher_thread_running = False
self.set_ui_enabled(True)
self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False
self.active_update_profile = None
def _handle_keep_duplicates_toggled(self, checked): def _handle_keep_duplicates_toggled(self, checked):
"""Shows the duplicate handling dialog when the checkbox is checked.""" """Shows the duplicate handling dialog when the checkbox is checked."""