7 Commits

Author SHA1 Message Date
Yuvi9587
9db89cfad0 Commit 2025-07-25 11:00:33 -07:00
Yuvi9587
0a6034a632 Update features.md 2025-07-25 10:49:51 -07:00
Yuvi9587
2da69e7017 Update features.md 2025-07-25 10:45:50 -07:00
Yuvi9587
3209770d00 Update LICENSE 2025-07-23 20:14:01 -07:00
Yuvi9587
337cdd342c Commit 2025-07-23 20:08:44 -07:00
Yuvi9587
d54b013bbc commit 2025-07-22 07:00:34 -07:00
Yuvi9587
2785fc1121 Update EmptyPopupDialog.py 2025-07-19 20:27:55 -07:00
10 changed files with 1084 additions and 587 deletions

24
LICENSE
View File

@@ -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. The above copyright notice and this permission notice shall be included in all
2. Proper credit must be given to the original author in any public use, distribution, or derivative works. copies or substantial portions of the Software.
3. Commercial use, resale, or sublicensing of the Software or any derivative works is strictly prohibited without explicit written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND... 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.

View File

@@ -1,192 +1,147 @@
# Kemono Downloader - Feature Guide <div>
This guide provides a comprehensive overview of all user interface elements, input fields, buttons, popups, and functionalities available in the Kemono Downloader. <h1>Kemono Downloader - Comprehensive Feature Guide</h1>
<p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
## 1. Main Interface & Workflow <hr>
These are the primary controls you'll interact with to initiate and manage downloads. <h2><strong>Main Window: Core Functionality</strong></h2>
<p>The application is divided into a configuration panel on the left and a status/log panel on the right.</p>
### 1.1. Core Inputs <h3><strong>Primary Inputs (Top-Left)</strong></h3>
**🔗 Creator/Post URL Input Field**   <ul>
- **Purpose**: Paste the URL of the content you want to download.   <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>
- **Supported Sites**: Kemono.su, Coomer.party, Simpcity.su.   <li><strong>🎨 Creator Selection Popup</strong>: This button opens a powerful dialog listing all known creators. From here, you can:
- **Supported URL Types**:   <ul>
  - Creator pages (e.g., `https://kemono.su/patreon/user/12345`).   <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>
  - Individual posts (e.g., `https://kemono.su/patreon/user/12345/post/98765`).   <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>
- **Note**: When ⭐ Favorite Mode is active, this field is disabled. For Simpcity.su URLs, the "Use Cookie" option is mandatory and auto-enabled. </ul>
</li>
**🎨 Creator Selection Button**   <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>
- **Icon**: 🎨 (Artist Palette)   <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>
- **Purpose**: Opens the "Creator Selection" dialog to browse and queue downloads from known creators.   </ul>
- **Dialog Features**:   <hr>
  - Loads creators from `creators.json`.   <h2><strong>Filtering & Naming (Left Panel)</strong></h2>
  - **Search Bar**: Filter creators by name.   <p>These features give you precise control over what gets downloaded and how it's named and organized.</p>
  - **Creator List**: Displays creators with their service (e.g., Patreon, Fanbox).   <ul>
  - **Selection**: Checkboxes to select one or more creators.   <li><strong>Filter by Character(s)</strong>: A powerful tool to download content featuring specific characters. You can enter multiple names separated by commas.
  - **Download Scope**: Organize downloads by Characters or Creators.   <ul>
  - **Add to Queue**: Adds selected creators or their posts to the download queue. <li><strong>Filter: [Scope] Button</strong>: This button changes how the character filter works:
<ul>
**Page Range (Start to End) Input Fields**   <li><strong>Title</strong>: Downloads posts only if a character's name is in the post title.</li>
- **Purpose**: Specify a range of pages to fetch for creator URLs.   <li><strong>Files</strong>: Downloads posts if a character's name is in any of the filenames within the post.</li>
- **Usage**: Enter the starting and ending page numbers.   <li><strong>Both</strong>: Combines the "Title" and "Files" logic.</li>
- **Behavior**:   <li><strong>Comments (Beta)</strong>: Downloads a post if a character's name is mentioned in the comments section.</li>
  - If blank, all pages are processed.   </ul>
  - Disabled for single post URLs. </li>
</ul>
**📁 Download Location Input Field & Browse Button**   </li>
- **Purpose**: Specify the main directory for downloaded files.   <li><strong>Skip with Words</strong>: A keyword-based filter to avoid unwanted content (e.g., <code>WIP</code>, <code>sketch</code>).
- **Usage**: Type the path or click "Browse..." to select a folder.   <ul>
- **Requirement**: Mandatory for all download operations. <li><strong>Scope: [Type] Button</strong>: This button changes how the skip filter works:
<ul>
### 1.2. Action Buttons <li><strong>Posts</strong>: Skips the entire post if a keyword is found in the title.</li>
**⬇️ Start Download / 🔗 Extract Links Button**   <li><strong>Files</strong>: Skips only individual files if a keyword is found in the filename.</li>
- **Purpose**: Initiates downloading or link extraction.   <li><strong>Both</strong>: Applies both levels of skipping.</li>
- **Behavior**:   </ul>
  - Shows "🔗 Extract Links" if "Only Links" is selected.   </li>
  - Otherwise, shows "⬇️ Start Download".   </ul>
  - Supports single-threaded or multi-threaded downloads based on settings. </li>
<li><strong>Remove Words from name</strong>: Automatically cleans downloaded filenames by removing any specified words (e.g., "patreon," "HD").</li>
**🔄 Restore Download Button**   </ul>
- **Visibility**: Appears if an incomplete session is detected on startup.   <h3><strong>File Type Filter (Radio Buttons)</strong></h3>
- **Purpose**: Resumes a previously interrupted download session. <p>This section lets you choose the kind of content you want:</p>
<ul>
**⏸️ Pause / ▶️ Resume Download Button**   <li><strong>All, Images/GIFs, Videos, 🎧 Only Audio, 📦 Only Archives</strong>: These options filter the downloads to only include the selected file types.</li>
- **Purpose**: Pause or resume the ongoing download.   <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>
- **Behavior**: Toggles between "Pause" and "Resume". Some UI settings can be changed while paused. <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>
</ul>
**❌ Cancel & Reset UI Button**   <hr>
- **Purpose**: Stops the current operation and performs a "soft" reset.   <h2><strong>Download Options & Advanced Settings (Checkboxes)</strong></h2>
- **Behavior**: Halts background threads, preserves URL and Download Location inputs, resets other settings. <ul>
<li><strong>Skip .zip</strong>: A simple toggle to ignore archive files during downloads.</li>
**🔄 Reset Button (in the log area)**   <li><strong>Download Thumbnails Only</strong>: Downloads only the small preview images instead of the full-resolution files.</li>
- **Purpose**: Performs a "hard" reset when no operation is active.   <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>
- **Behavior**: Clears all inputs, resets options to default, and clears logs. <li><strong>Compress to WebP</strong>: Saves disk space by automatically converting large images into the efficient WebP format.</li>
<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>
## 2. Filtering & Content Selection <li><strong>Subfolder per Post</strong>: Organizes downloads by creating a unique folder for each post, named after the post's title.</li>
These options allow precise control over downloaded content. <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>
<li><strong>Separate Folders by Known.txt</strong>: This enables the automatic folder organization system based on your "Known Names" list.</li>
### 2.1. Content Filtering <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>
**🎯 Filter by Character(s) Input Field**   <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>
- **Purpose**: Download content related to specific characters or series.   <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>
- **Usage**: Enter comma-separated character names.   </ul>
- **Advanced Syntax**:   <hr>
  - `Nami`: Simple filter.   <h2><strong>Known Names Management (Bottom-Left)</strong></h2>
  - `(Vivi, Ulti)`: Grouped filter. Matches posts with "Vivi" OR "Ulti". Creates a shared folder like `Vivi Ulti` if subfolders are enabled.   <p>This powerful feature automates the creation of organized, named folders.</p>
  - `(Boa, Hancock)~`: Aliased filter. Treats "Boa" and "Hancock" as the same entity. <ul>
<li><strong>Known Shows/Characters List</strong>: Displays all the names and groups you've saved.</li>
**Filter: [Type] Button (Character Filter Scope)**   <li><strong>Search...</strong>: Filters the list to quickly find a name.</li>
- **Purpose**: Defines where the character filter is applied. Cycles on click.   <li><strong>Open Known.txt</strong>: Opens the source file in a text editor for advanced manual editing.</li>
- **Options**:   <li><strong>Add New Name</strong>:
  - **Filter: Title** (Default): Matches post titles.   <ul>
  - **Filter: Files**: Matches filenames.   <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: Both**: Checks title first, then 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: Comments (Beta)**: Checks filenames, then post comments. </ul>
</li>
**🚫 Skip with Words Input Field**   <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>
- **Purpose**: Exclude posts/files with specified keywords (e.g., `WIP`, `sketch`). <li><strong>🗑️ Delete Selected</strong>: Removes highlighted names from your list.</li>
</ul>
**Scope: [Type] Button (Skip Words Scope)**   <hr>
- **Purpose**: Defines where skip words are applied. Cycles on click.   <h2><strong>Action Buttons & Status Controls</strong></h2>
- **Options**:   <ul>
  - **Scope: Posts** (Default): Skips posts if the title contains a skip word.   <li><strong>⬇️ Start Download / 🔗 Extract Links</strong>: The main action button. Its function is dynamic:
  - **Scope: Files**: Skips files if the filename contains a skip word.   <ul>
  - **Scope: Both**: Applies both rules. <li><strong>Normal Mode</strong>: Starts the download based on the current settings.</li>
<li><strong>Update Mode</strong>: After selecting a creator profile, this button changes to <strong>🔄 Check for Updates</strong>.</li>
**✂️ Remove Words from Name Input Field**   <li><strong>Update Confirmation</strong>: After new posts are found, it changes to <strong>⬇️ Start Download (X new)</strong>.</li>
- **Purpose**: Remove unwanted text from filenames (e.g., `patreon`, `[HD]`). <li><strong>Link Extraction Mode</strong>: The text changes to <strong>🔗 Extract Links</strong>.</li>
</ul>
### 2.2. File Type Filtering </li>
**Filter Files (Radio Buttons)**   <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>
- **Purpose**: Select file types to download.   <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>
- **Options**:   <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:
  - **All**: All file types.   <ul>
  - **Images/GIFs**: Common image formats.   <li>Select specific files to <strong>Retry</strong> downloading.</li>
  - **Videos**: Common video formats.   <li><strong>Export</strong> the list of failed URLs to a <code>.txt</code> file.</li>
  - **🎧 Only Audio**: Common audio formats.   </ul>
  - **📦 Only Archives**: Only `.zip` and `.rar` files.   </li>
  - **🔗 Only Links**: Extracts external links without downloading 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>
<li><strong>⚙️ (Settings)</strong>: Opens the main Settings dialog.</li>
**Skip .zip / Skip .rar Checkboxes**   <li><strong>📜 (History)</strong>: Opens the Download History dialog.</li>
- **Purpose**: Skip downloading `.zip` or `.rar` files.   <li><strong>? (Help)</strong>: Opens a helpful guide explaining the application's features.</li>
- **Behavior**: Disabled when "📦 Only Archives" is active. <li><strong>❤️ Support</strong>: Opens a dialog with information on how to support the developer.</li>
</ul>
## 3. Download Customization <hr>
Options to refine the download process and output. <h2><strong>Specialized Modes & Features</strong></h2>
<h3><strong>⭐ Favorite Mode</strong></h3>
- **Download Thumbnails Only**: Downloads small preview images instead of full-resolution files.   <p>Activating this mode transforms the UI for managing saved collections:</p>
- **Scan Content for Images**: Scans post HTML for `<img>` tags, crucial for images in descriptions.   <ul>
- **Compress to WebP**: Converts images to WebP format (requires Pillow library). <li>The URL input is disabled.</li>
- **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`). <li>The main action buttons are replaced with:
- **🗄️ Custom Folder Name (Single Post Only)**: Specify a custom folder name for a single post's content (appears if subfolders are enabled). <ul>
<li><strong>🖼️ Favorite Artists</strong>: Opens a dialog to browse and queue downloads from your saved favorite creators.</li>
## 4. 📖 Manga/Comic Mode <li><strong>📄 Favorite Posts</strong>: Opens a dialog to browse and queue downloads for specific saved favorite posts.</li>
A mode for downloading creator feeds in chronological order, ideal for sequential content. </ul>
</li>
- **Activation**: Active when downloading a creator's entire feed (not a single post).   <li><strong>Scope: [Location] Button</strong>: Toggles where the favorited content is saved:
- **Core Behavior**: Fetches all posts, processing from oldest to newest.   <ul>
- **Filename Style Toggle Button (in the log area)**:   <li><strong>Selected Location</strong>: Saves all content directly into the main "Download Location".</li>
  - **Purpose**: Controls file naming in Manga Mode. Cycles on click.   <li><strong>Artist Folders</strong>: Creates a subfolder for each artist inside the main "Download Location".</li>
  - **Options**:   </ul>
    - **Name: Post Title**: First file named after post title; others keep original names.   </li>
    - **Name: Original File**: Files keep server-provided names, with optional prefix.   </ul>
    - **Name: Title+G.Num**: Global numbering with post title prefix (e.g., `Chapter 1_001.jpg`).   <h3><strong>📖 Manga/Comic Mode</strong></h3>
    - **Name: Date Based**: Sequential naming by post date (e.g., `001.jpg`), with optional prefix.   <p>This mode is designed for sequential content and has several effects:</p>
    - **Name: Post ID**: Files named after post ID to avoid clashes.   <ul>
    - **Name: Date + Title**: Combines post date and title for filenames. <li><strong>Reverses Download Order</strong>: It fetches and downloads posts from <strong>oldest to newest</strong>.</li>
<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>
## 5. Folder Organization & Known.txt <li><strong>Disables Multithreading (for certain styles)</strong>: To guarantee perfect sequential numbering, multithreading for posts is automatically disabled for certain naming styles.</li>
Controls for structuring downloaded content. </ul>
<h3><strong>Session & Error Management</strong></h3>
- **Separate Folders by Name/Title Checkbox**: Enables automatic subfolder creation.   <ul>
- **Subfolder per Post Checkbox**: Creates subfolders for each post, named after the post title.   <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>
- **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. <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>
- **Known.txt Management UI (Bottom Left)**:   </ul>
  - **Purpose**: Manages a local `Known.txt` file for series, characters, or terms used in folder creation.   <h3><strong>Logging & Monitoring</strong></h3>
  - **List Display**: Shows primary names from `Known.txt`.   <ul>
  - ** Add Button**: Adds names or groups (e.g., `(Character A, Alias B)~`).   <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 to Filter Button**: Select names from `Known.txt` for the character filter.   <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>
  - **🗑️ Delete Selected Button**: Removes selected names from `Known.txt`.   </ul>
  - **Open Known.txt Button**: Opens the file in the default text editor.   </div>
  - **❓ 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

@@ -59,6 +59,7 @@ LANGUAGE_KEY = "currentLanguageV1"
DOWNLOAD_LOCATION_KEY = "downloadLocationV1" DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution" RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor" UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
# --- UI Constants and Identifiers --- # --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>" HTML_PREFIX = "<!HTML!>"

View File

@@ -41,6 +41,9 @@ class DownloadManager:
self.total_downloads = 0 self.total_downloads = 0
self.total_skips = 0 self.total_skips = 0
self.all_kept_original_filenames = [] self.all_kept_original_filenames = []
self.creator_profiles_dir = None
self.current_creator_name_for_profile = None
self.current_creator_profile_path = None
def _log(self, message): def _log(self, message):
"""Puts a progress message into the queue for the UI.""" """Puts a progress message into the queue for the UI."""
@@ -58,6 +61,13 @@ class DownloadManager:
if self.is_running: if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.") self._log("❌ Cannot start a new session: A session is already in progress.")
return return
creator_profile_data = self._setup_creator_profile(config)
creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_profile_data)
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
self.is_running = True self.is_running = True
self.cancellation_event.clear() self.cancellation_event.clear()
self.pause_event.clear() self.pause_event.clear()
@@ -76,7 +86,7 @@ class DownloadManager:
if should_use_multithreading_for_posts: if should_use_multithreading_for_posts:
fetcher_thread = threading.Thread( fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool, target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data), args=(config, restore_data, creator_profile_data), # Add argument here
daemon=True daemon=True
) )
fetcher_thread.start() fetcher_thread.start()
@@ -112,6 +122,11 @@ class DownloadManager:
try: try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS) num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_') self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set()
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_ids)
if restore_data: if restore_data:
all_posts = restore_data['all_posts_data'] all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids']) processed_ids = set(restore_data['processed_post_ids'])
@@ -144,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.") self._log("🏁 All processing tasks have completed or been cancelled.")
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)
@@ -196,12 +211,52 @@ class DownloadManager:
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)}) self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
if history: if history:
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)}) self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
post_id = history.get('post_id')
if post_id and self.current_creator_profile_path:
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(profile_data)
except Exception as e: except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}") self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped self.total_skips += 1 # Count errored posts as skipped
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)}) self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile."""
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
if not self.current_creator_name_for_profile:
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
return {}
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(self.creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
if os.path.exists(self.current_creator_profile_path):
try:
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, data):
"""Saves the provided data to the current creator's profile file."""
if not self.current_creator_profile_path:
return
try:
temp_path = self.current_creator_profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, self.current_creator_profile_path)
except OSError as e:
self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}")
def cancel_session(self): def cancel_session(self):
"""Cancels the current running session.""" """Cancels the current running session."""
if not self.is_running: if not self.is_running:

View File

@@ -238,13 +238,24 @@ class PostProcessorWorker:
if self.manga_mode_active: if self.manga_mode_active:
if self.manga_filename_style == STYLE_ORIGINAL_NAME: if self.manga_filename_style == STYLE_ORIGINAL_NAME:
filename_to_save_in_main_path = cleaned_original_api_filename # Get the post's publication or added date
if self.manga_date_prefix and self.manga_date_prefix.strip(): published_date_str = self.post.get('published')
cleaned_prefix = clean_filename(self.manga_date_prefix.strip()) added_date_str = self.post.get('added')
if cleaned_prefix: formatted_date_str = "nodate" # Fallback if no date is found
filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}"
date_to_use_str = published_date_str or added_date_str
if date_to_use_str:
try:
# Extract just the YYYY-MM-DD part from the timestamp
formatted_date_str = date_to_use_str.split('T')[0]
except Exception:
self.logger(f" ⚠️ Could not parse date '{date_to_use_str}'. Using 'nodate' prefix.")
else: else:
self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.") self.logger(f" ⚠️ Post ID {original_post_id_for_log} has no date. Using 'nodate' prefix.")
# Combine the date with the cleaned original filename
filename_to_save_in_main_path = f"{formatted_date_str}_{cleaned_original_api_filename}"
was_original_name_kept_flag = True was_original_name_kept_flag = True
elif self.manga_filename_style == STYLE_POST_TITLE: elif self.manga_filename_style == STYLE_POST_TITLE:
if post_title and post_title.strip(): if post_title and post_title.strip():
@@ -583,6 +594,33 @@ 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
@@ -599,15 +637,17 @@ 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)
@@ -680,7 +720,6 @@ 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)
@@ -848,17 +887,6 @@ 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)")
@@ -869,6 +897,7 @@ 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:
@@ -1017,6 +1046,28 @@ 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()
@@ -1385,7 +1436,17 @@ class PostProcessorWorker:
if not all_files_from_post_api: if not all_files_from_post_api:
self.logger(f" No files found to download for post {post_id}.") self.logger(f" No files found to download for post {post_id}.")
result_tuple = (0, 0, [], [], [], None, None) history_data_for_no_files_post = {
'post_title': post_title,
'post_id': post_id,
'service': self.service,
'user_id': self.user_id,
'top_file_name': "N/A (No Files)",
'num_files': 0,
'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, 0, [], [], [], history_data_for_no_files_post, None)
return result_tuple return result_tuple
files_to_download_info_list = [] files_to_download_info_list = []

View File

@@ -15,9 +15,9 @@ except ImportError:
try: try:
import gdown import gdown
GDOWN_AVAILABLE = True GDRIVE_AVAILABLE = True
except ImportError: except ImportError:
GDOWN_AVAILABLE = False GDRIVE_AVAILABLE = False
# --- Helper Functions --- # --- Helper Functions ---
@@ -46,75 +46,76 @@ def _get_filename_from_headers(headers):
# --- Main Service Downloader Functions --- # --- Main Service Downloader Functions ---
def download_mega_file(mega_link, download_path=".", logger_func=print): def download_mega_file(mega_url, download_path, logger_func=print):
""" """
Downloads a file from a public Mega.nz link. Downloads a file from a Mega.nz URL.
Handles both public links and links that include a decryption key.
Args:
mega_link (str): The public Mega.nz link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
""" """
if not MEGA_AVAILABLE: if not MEGA_AVAILABLE:
logger_func("Error: mega.py library is not installed. Cannot download from Mega.") logger_func("Mega download failed: 'mega.py' library is not installed.")
logger_func(" Please install it: pip install mega.py") return
raise ImportError("mega.py library not found.")
logger_func(f" [Mega] Initializing Mega client...") logger_func(f" [Mega] Initializing Mega client...")
try: try:
mega_client = Mega() mega = Mega()
m = mega_client.login() # Anonymous login is sufficient for public links
logger_func(f" [Mega] Attempting to download from: {mega_link}") m = mega.login()
if not os.path.exists(download_path): # --- MODIFIED PART: Added error handling for invalid links ---
os.makedirs(download_path, exist_ok=True)
logger_func(f" [Mega] Created download directory: {download_path}")
# The download_url method handles file info fetching and saving internally.
downloaded_file_path = m.download_url(mega_link, dest_path=download_path)
if downloaded_file_path and os.path.exists(downloaded_file_path):
logger_func(f" [Mega] ✅ File downloaded successfully! Saved as: {downloaded_file_path}")
else:
raise Exception(f"Mega download failed or file not found. Returned: {downloaded_file_path}")
except Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during Mega download: {e}")
traceback.print_exc(limit=2)
raise # Re-raise the exception to be handled by the calling worker
def download_gdrive_file(gdrive_link, download_path=".", logger_func=print):
"""
Downloads a file from a public Google Drive link using the gdown library.
Args:
gdrive_link (str): The public Google Drive link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
"""
if not GDOWN_AVAILABLE:
logger_func("❌ Error: gdown library is not installed. Cannot download from Google Drive.")
logger_func(" Please install it: pip install gdown")
raise ImportError("gdown library not found.")
logger_func(f" [GDrive] Attempting to download: {gdrive_link}")
try: try:
if not os.path.exists(download_path): file_details = m.find(mega_url)
os.makedirs(download_path, exist_ok=True) if file_details is None:
logger_func(f" [GDrive] Created download directory: {download_path}") logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
return
except (ValueError, json.JSONDecodeError) as e:
# This block catches the "Expecting value" error
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
return
except Exception as e:
# Catch other potential errors from the mega.py library
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
return
# --- END OF MODIFIED PART ---
# gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats. filename = file_details[1]['a']['n']
output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True) logger_func(f" [Mega] File found: '{filename}'. Starting download...")
if output_file_path and os.path.exists(output_file_path): # Sanitize filename before saving
logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}") safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
else: final_path = os.path.join(download_path, safe_filename)
raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}")
# Check if file already exists
if os.path.exists(final_path):
logger_func(f" [Mega] File '{safe_filename}' already exists. Skipping download.")
return
# Start the download
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
except Exception as e: except Exception as e:
logger_func(f" [GDrive] ❌ An error occurred during Google Drive download: {e}") logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
traceback.print_exc(limit=2)
raise def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link."""
if not GDRIVE_AVAILABLE:
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
return
try:
logger_func(f" [G-Drive] Starting download for: {url}")
# --- MODIFIED PART: Added a message and set quiet=True ---
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
# --- END OF MODIFIED PART ---
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
else:
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
except Exception as e:
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print): def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
""" """

View File

@@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
QSplitter, QProgressBar, QWidget QSplitter, QProgressBar, QWidget, QFileDialog
) )
# --- Local Application Imports --- # --- Local Application Imports ---
@@ -151,6 +151,8 @@ class EmptyPopupDialog (QDialog ):
app_icon =get_app_icon_object () app_icon =get_app_icon_object ()
if app_icon and not app_icon .isNull (): if app_icon and not app_icon .isNull ():
self .setWindowIcon (app_icon ) self .setWindowIcon (app_icon )
self.update_profile_data = None
self.update_creator_name = None
self .selected_creators_for_queue =[] self .selected_creators_for_queue =[]
self .globally_selected_creators ={} self .globally_selected_creators ={}
self .fetched_posts_data ={} self .fetched_posts_data ={}
@@ -205,6 +207,9 @@ class EmptyPopupDialog (QDialog ):
self .scope_button .clicked .connect (self ._toggle_scope_mode ) self .scope_button .clicked .connect (self ._toggle_scope_mode )
left_bottom_buttons_layout .addWidget (self .scope_button ) left_bottom_buttons_layout .addWidget (self .scope_button )
left_pane_layout .addLayout (left_bottom_buttons_layout ) left_pane_layout .addLayout (left_bottom_buttons_layout )
self.update_button = QPushButton()
self.update_button.clicked.connect(self._handle_update_check)
left_bottom_buttons_layout.addWidget(self.update_button)
self .right_pane_widget =QWidget () self .right_pane_widget =QWidget ()
@@ -315,6 +320,31 @@ class EmptyPopupDialog (QDialog ):
except AttributeError : except AttributeError :
pass pass
def _handle_update_check(self):
"""Opens a dialog to select a creator profile and loads it for an update session."""
appdata_dir = os.path.join(self.app_base_dir, "appdata")
profiles_dir = os.path.join(appdata_dir, "creator_profiles")
if not os.path.isdir(profiles_dir):
QMessageBox.warning(self, "Directory Not Found", f"The creator profiles directory does not exist yet.\n\nPath: {profiles_dir}")
return
filepath, _ = QFileDialog.getOpenFileName(self, "Select Creator Profile for Update", profiles_dir, "JSON Files (*.json)")
if filepath:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'creator_url' not in data or 'processed_post_ids' not in data:
raise ValueError("Invalid profile format.")
self.update_profile_data = data
self.update_creator_name = os.path.basename(filepath).replace('.json', '')
self.accept() # Close the dialog and signal success
except Exception as e:
QMessageBox.critical(self, "Error Loading Profile", f"Could not load or parse the selected profile file:\n\n{e}")
def _handle_fetch_posts_click (self ): def _handle_fetch_posts_click (self ):
selected_creators =list (self .globally_selected_creators .values ()) selected_creators =list (self .globally_selected_creators .values ())
print(f"[DEBUG] Selected creators for fetch: {selected_creators}") print(f"[DEBUG] Selected creators for fetch: {selected_creators}")
@@ -370,6 +400,7 @@ class EmptyPopupDialog (QDialog ):
self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected")) self .add_selected_button .setText (self ._tr ("creator_popup_add_selected_button","Add Selected"))
self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts")) self .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts"))
self ._update_scope_button_text_and_tooltip () self ._update_scope_button_text_and_tooltip ()
self.update_button.setText(self._tr("check_for_updates_button", "Check for Updates"))
self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title...")) self .posts_search_input .setPlaceholderText (self ._tr ("creator_popup_posts_search_placeholder","Search fetched posts by title..."))
@@ -938,6 +969,9 @@ 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

@@ -6,7 +6,7 @@ import json
from PyQt5.QtCore import Qt, QStandardPaths from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
) )
# --- Local Application Imports --- # --- Local Application Imports ---
@@ -15,7 +15,7 @@ from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object from ..main_window import get_app_icon_object
from ...config.constants import ( from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY, THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY
) )
@@ -35,7 +35,7 @@ class FutureSettingsDialog(QDialog):
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800 screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0 scale_factor = screen_height / 800.0
base_min_w, base_min_h = 420, 320 # Adjusted height for new layout base_min_w, base_min_h = 420, 360 # Adjusted height for new layout
scaled_min_w = int(base_min_w * scale_factor) scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor) scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h) self.setMinimumSize(scaled_min_w, scaled_min_h)
@@ -93,6 +93,11 @@ class FutureSettingsDialog(QDialog):
download_window_layout.addWidget(self.default_path_label, 1, 0) download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1) download_window_layout.addWidget(self.save_path_button, 1, 1)
# Save Creator.json Checkbox
self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box) main_layout.addWidget(self.download_window_group_box)
main_layout.addStretch(1) main_layout.addStretch(1)
@@ -102,6 +107,20 @@ class FutureSettingsDialog(QDialog):
self.ok_button.clicked.connect(self.accept) self.ok_button.clicked.connect(self.accept)
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom) main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
def _load_checkbox_states(self):
"""Loads the initial state for all checkboxes from settings."""
self.save_creator_json_checkbox.blockSignals(True)
# Default to True so the feature is on by default for users
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
self.save_creator_json_checkbox.setChecked(should_save)
self.save_creator_json_checkbox.blockSignals(False)
def _creator_json_setting_changed(self, state):
"""Saves the state of the 'Save Creator.json' checkbox."""
is_checked = state == Qt.Checked
self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked)
self.parent_app.settings.sync()
def _tr(self, key, default_text=""): def _tr(self, key, default_text=""):
if callable(get_translation) and self.parent_app: if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text) return get_translation(self.parent_app.current_selected_language, key, default_text)
@@ -122,6 +141,7 @@ class FutureSettingsDialog(QDialog):
# Download & Window Group Labels # Download & Window Group Labels
self.window_size_label.setText(self._tr("window_size_label", "Window Size:")) self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:")) self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
# Buttons and Controls # Buttons and Controls
self._update_theme_toggle_button_text() self._update_theme_toggle_button_text()
@@ -132,6 +152,7 @@ class FutureSettingsDialog(QDialog):
# Populate dropdowns # Populate dropdowns
self._populate_display_combo_boxes() self._populate_display_combo_boxes()
self._populate_language_combo_box() self._populate_language_combo_box()
self._load_checkbox_states()
def _apply_theme(self): def _apply_theme(self):
if self.parent_app and self.parent_app.current_theme == "dark": if self.parent_app and self.parent_app.current_theme == "dark":

View File

@@ -23,6 +23,11 @@ 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
@@ -97,6 +102,8 @@ class DownloaderApp (QWidget ):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN) self.settings = QSettings(CONFIG_ORGANIZATION_NAME, CONFIG_APP_NAME_MAIN)
self.active_update_profile = None
self.new_posts_for_update = []
self.is_finishing = False self.is_finishing = False
saved_res = self.settings.value(RESOLUTION_KEY, "Auto") saved_res = self.settings.value(RESOLUTION_KEY, "Auto")
@@ -113,9 +120,13 @@ class DownloaderApp (QWidget ):
else: else:
self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) self.app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
self.config_file = os.path.join(self.app_base_dir, "appdata", "Known.txt") executable_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else self.app_base_dir
self.session_file_path = os.path.join(self.app_base_dir, "appdata", "session.json") user_data_path = os.path.join(executable_dir, "appdata")
self.persistent_history_file = os.path.join(self.app_base_dir, "appdata", "download_history.json") os.makedirs(user_data_path, exist_ok=True)
self.config_file = os.path.join(user_data_path, "Known.txt")
self.session_file_path = os.path.join(user_data_path, "session.json")
self.persistent_history_file = os.path.join(user_data_path, "download_history.json")
self.download_thread = None self.download_thread = None
self.thread_pool = None self.thread_pool = None
@@ -222,6 +233,8 @@ 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
print(f" Known.txt will be loaded/saved at: {self.config_file}") print(f" Known.txt will be loaded/saved at: {self.config_file}")
@@ -253,7 +266,7 @@ class DownloaderApp (QWidget ):
self.download_location_label_widget = None self.download_location_label_widget = None
self.remove_from_filename_label_widget = None self.remove_from_filename_label_widget = None
self.skip_words_label_widget = None self.skip_words_label_widget = None
self.setWindowTitle("Kemono Downloader v6.1.0") self.setWindowTitle("Kemono Downloader v6.2.0")
setup_ui(self) setup_ui(self)
self._connect_signals() self._connect_signals()
self.log_signal.emit(" Local API server functionality has been removed.") self.log_signal.emit(" Local API server functionality has been removed.")
@@ -294,6 +307,45 @@ class DownloaderApp (QWidget ):
if msg_box.clickedButton() == restart_button: if msg_box.clickedButton() == restart_button:
self._request_restart_application() self._request_restart_application()
def _setup_creator_profile(self, creator_name, session_file_path):
"""Prepares the path and loads data for the current creator's profile."""
if not creator_name:
self.log_signal.emit("⚠️ Cannot create creator profile: Name not provided.")
return {}
appdata_dir = os.path.dirname(session_file_path)
creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(creator_name) + ".json"
profile_path = os.path.join(creator_profiles_dir, safe_filename)
if os.path.exists(profile_path):
try:
with open(profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self.log_signal.emit(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, creator_name, data, session_file_path):
"""Saves the provided data to the current creator's profile file."""
if not creator_name:
return
appdata_dir = os.path.dirname(session_file_path)
creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
safe_filename = clean_folder_name(creator_name) + ".json"
profile_path = os.path.join(creator_profiles_dir, safe_filename)
try:
temp_path = profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, profile_path)
except OSError as e:
self.log_signal.emit(f"❌ Error saving creator profile to '{profile_path}': {e}")
def _create_initial_session_file(self, api_url_for_session, override_output_dir_for_session, remaining_queue=None): def _create_initial_session_file(self, api_url_for_session, override_output_dir_for_session, remaining_queue=None):
"""Creates the initial session file at the start of a new download.""" """Creates the initial session file at the start of a new download."""
if self.is_restore_pending: if self.is_restore_pending:
@@ -478,7 +530,7 @@ class DownloaderApp (QWidget ):
def _update_button_states_and_connections(self): def _update_button_states_and_connections(self):
""" """
Updates the text and click connections of the main action buttons Updates the text and click connections of the main action buttons
based on the current application state (downloading, paused, restore pending, idle). based on the current application state.
""" """
try: self.download_btn.clicked.disconnect() try: self.download_btn.clicked.disconnect()
except TypeError: pass except TypeError: pass
@@ -489,7 +541,38 @@ class DownloaderApp (QWidget ):
is_download_active = self._is_download_active() is_download_active = self._is_download_active()
if self.is_restore_pending: if self.active_update_profile and self.new_posts_for_update and not is_download_active:
# State: Update confirmation (new posts found, waiting for user to start)
num_new = len(self.new_posts_for_update)
self.download_btn.setText(self._tr("start_download_new_button_text", f"⬇️ Start Download ({num_new} new)"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.download_btn.setToolTip(self._tr("start_download_new_tooltip", "Click to download the new posts found."))
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False)
self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_update_selection)
self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to cancel the update and clear the selection."))
elif self.active_update_profile and not is_download_active:
# State: Update check (profile loaded, waiting for user to check)
self.download_btn.setText(self._tr("check_for_updates_button_text", "🔄 Check For Updates"))
self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download)
self.download_btn.setToolTip(self._tr("check_for_updates_tooltip", "Click to check for new posts from this creator."))
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause Download"))
self.pause_btn.setEnabled(False)
self.cancel_btn.setText(self._tr("clear_selection_button_text", "🗑️ Clear Selection"))
self.cancel_btn.setEnabled(True)
self.cancel_btn.clicked.connect(self._clear_update_selection)
self.cancel_btn.setToolTip(self._tr("clear_selection_tooltip", "Click to clear the loaded creator profile and return to normal mode."))
elif self.is_restore_pending:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download"))
self.download_btn.setEnabled(True) self.download_btn.setEnabled(True)
self.download_btn.clicked.connect(self.start_download) self.download_btn.clicked.connect(self.start_download)
@@ -507,7 +590,7 @@ class DownloaderApp (QWidget ):
elif is_download_active: elif is_download_active:
self.download_btn.setText(self._tr("start_download_button_text", "⬇️ Start Download")) 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.download_btn.setEnabled(False)
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.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.setEnabled(True)
@@ -524,13 +607,19 @@ class DownloaderApp (QWidget ):
self.download_btn.clicked.connect(self.start_download) self.download_btn.clicked.connect(self.start_download)
self.pause_btn.setText(self._tr("pause_download_button_text", "⏸️ Pause 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.setEnabled(False)
self.pause_btn.setToolTip(self._tr("pause_download_button_tooltip", "Click to pause the ongoing download process.")) 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.setText(self._tr("cancel_button_text", "❌ Cancel & Reset UI"))
self.cancel_btn.setEnabled(False) # No active download to cancel self.cancel_btn.setEnabled(False)
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).")) 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 _clear_update_selection(self):
"""Clears the loaded creator profile and fully resets the UI to its default state."""
self.log_signal.emit(" Update selection cleared. Resetting UI to defaults.")
self.active_update_profile = None
self.new_posts_for_update = []
self._reset_ui_to_defaults()
def _retranslate_main_ui (self ): def _retranslate_main_ui (self ):
"""Retranslates static text elements in the main UI.""" """Retranslates static text elements in the main UI."""
@@ -1341,15 +1430,21 @@ 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. Checks if the fetcher thread is done AND if all submitted tasks have been processed OR if a cancellation was requested.
If so, finalizes the download. If so, finalizes the download. This is the central point for completion logic.
""" """
fetcher_is_done = not self.is_fetcher_thread_running fetcher_is_done = not self.is_fetcher_thread_running
all_workers_are_done = (self.total_posts_to_process > 0 and self.processed_posts_count >= self.total_posts_to_process) all_workers_are_done = (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: if fetcher_is_done and (all_workers_are_done or is_cancelled):
self.log_signal.emit("🏁 All fetcher and worker tasks complete.") if not self.is_finishing:
self.finished_signal.emit(self.download_counter, self.skip_counter, self.cancellation_event.is_set(), self.all_kept_original_filenames) if is_cancelled:
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 ):
""" """
@@ -1621,32 +1716,65 @@ class DownloaderApp (QWidget ):
post_title ,link_text ,link_url ,platform ,decryption_key =link_data post_title ,link_text ,link_url ,platform ,decryption_key =link_data
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked () is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
# --- FONT STYLE DEFINITION ---
# Use a clean, widely available sans-serif font family for a modern look.
font_style = "font-family: 'Segoe UI', Helvetica, Arial, sans-serif;"
if is_only_links_mode : if is_only_links_mode :
if post_title != self._current_link_post_title: if post_title != self._current_link_post_title:
if self._current_link_post_title is not None: if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">' separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html) self.log_signal.emit(separator_html)
title_html = f'{HTML_PREFIX}<h3 style="color: #87CEEB; margin-bottom: 5px; margin-top: 8px;">{html.escape(post_title)}</h3>'
self.log_signal.emit(title_html)
self._current_link_post_title = post_title
display_text = html.escape(link_text.strip() if link_text.strip() else link_url)
link_html_parts = [
f'<div style="margin-left: 20px; margin-bottom: 4px;">'
f'• <a href="{link_url}" style="color: #A9D0F5; text-decoration: none;">{display_text}</a>'
f' <span style="color: #999;">({html.escape(platform)})</span>'
]
if decryption_key: # Apply font style to the title
link_html_parts.append( title_html = f'<p style="{font_style} font-size: 11pt; color: #87CEEB; margin-top: 2px; margin-bottom: 2px;"><b>{html.escape(post_title)}</b></p>'
f'<br><span style="margin-left: 15px; color: #f0ad4e; font-size: 9pt;">' self.log_signal.emit(HTML_PREFIX + title_html)
f'Key: {html.escape(decryption_key)}</span>' self._current_link_post_title = post_title
# Use the "smarter" logic to decide what text to show for the link
cleaned_link_text = link_text.strip()
display_text = ""
if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url:
display_text = cleaned_link_text
else:
try:
path = urlparse(link_url).path
filename = os.path.basename(path)
if filename:
display_text = filename
except Exception:
pass
if not display_text:
display_text = link_url
# Truncate long display text
if len(display_text) > 50:
display_text = display_text[:50].strip() + "..."
# Format the output as requested
platform_display = platform.capitalize()
# Escape parts that will be displayed as text
escaped_url = html.escape(link_url)
escaped_display_text = html.escape(f"({display_text})")
# Apply font style to the link information and wrap in a paragraph tag
link_html_line = (
f'<p style="{font_style} font-size: 9.5pt; margin-left: 10px; margin-top: 1px; margin-bottom: 1px;">'
f" <span style='color: #E0E0E0;'>{platform_display} - {escaped_url} - {escaped_display_text}</span>"
) )
link_html_parts.append('</div>') if decryption_key:
escaped_key = html.escape(f"(Decryption Key: {decryption_key})")
link_html_line += f" <span style='color: #f0ad4e;'>{escaped_key}</span>"
link_html_line += '</p>'
# Emit the entire line as a single HTML signal
self.log_signal.emit(HTML_PREFIX + link_html_line)
final_link_html = f'{HTML_PREFIX}{"".join(link_html_parts)}'
self.log_signal.emit(final_link_html)
elif self .show_external_links : elif self .show_external_links :
# This part for the secondary log remains unchanged
separator ="-"*45 separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}" formatted_link_info = f"{link_text} - {link_url} - {platform}"
if decryption_key: if decryption_key:
@@ -1916,66 +2044,93 @@ class DownloaderApp (QWidget ):
search_term =self .link_search_input .text ().lower ().strip ()if self .link_search_input else "" search_term =self .link_search_input .text ().lower ().strip ()if self .link_search_input else ""
# This block handles the "Download Progress" view for Mega/Drive links and should be kept
if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS : if self .mega_download_log_preserved_once and self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log.")
return
self .log_signal .emit ("INTERNAL: _filter_links_log - Preserving Mega log (due to mega_download_log_preserved_once).")
elif self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS : elif self .only_links_log_display_mode ==LOG_DISPLAY_DOWNLOAD_PROGRESS :
self .log_signal .emit ("INTERNAL: _filter_links_log - In Progress View. Clearing for placeholder.")
if self .main_log_output :self .main_log_output .clear () if self .main_log_output :self .main_log_output .clear ()
self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for progress placeholder.")
self .log_signal .emit (" Switched to Mega download progress view. Extracted links are hidden.\n" self .log_signal .emit (" Switched to Mega download progress view. Extracted links are hidden.\n"
" Perform a Mega download to see its progress here, or switch back to 🔗 view.") " Perform a Mega download to see its progress here, or switch back to 🔗 view.")
self .log_signal .emit ("INTERNAL: _filter_links_log - Placeholder message emitted.") return
else : # Simplified logic: Clear the log and re-trigger the display process
# The main display logic is now fully handled by _display_and_schedule_next
self .log_signal .emit ("INTERNAL: _filter_links_log - In links view branch. About to clear.")
if self .main_log_output :self .main_log_output .clear () if self .main_log_output :self .main_log_output .clear ()
self .log_signal .emit ("INTERNAL: _filter_links_log - Cleared for links view.") self._current_link_post_title = None # Reset the title tracking for the new display pass
current_title_for_display =None
any_links_displayed_this_call =False
separator_html ="<br>"+"-"*45 +"<br>"
# Create a new temporary queue containing only the links that match the search term
filtered_link_queue = deque()
for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache : for post_title ,link_text ,link_url ,platform ,decryption_key in self .extracted_links_cache :
matches_search =(not search_term or matches_search =(not search_term or
search_term in link_text .lower ()or search_term in link_text .lower ()or
search_term in link_url .lower ()or search_term in link_url .lower ()or
search_term in platform .lower ()or search_term in platform .lower ()or
(decryption_key and search_term in decryption_key .lower ())) (decryption_key and search_term in decryption_key .lower ()))
if not matches_search : if matches_search :
continue filtered_link_queue.append((post_title, link_text, link_url, platform, decryption_key))
any_links_displayed_this_call =True if not filtered_link_queue:
if post_title !=current_title_for_display :
if current_title_for_display is not None :
if self .main_log_output :self .main_log_output .insertHtml (separator_html )
title_html =f'<b style="color: #87CEEB;">{html .escape (post_title )}</b><br>'
if self .main_log_output :self .main_log_output .insertHtml (title_html )
current_title_for_display =post_title
max_link_text_len =50
display_text =(link_text [:max_link_text_len ].strip ()+"..."if len (link_text )>max_link_text_len else link_text .strip ())
plain_link_info_line =f"{display_text } - {link_url } - {platform }"
if decryption_key :
plain_link_info_line +=f" (Decryption Key: {decryption_key })"
if self .main_log_output :
self .main_log_output .append (plain_link_info_line )
if any_links_displayed_this_call :
if self .main_log_output :self .main_log_output .append ("")
elif not search_term and self .main_log_output :
self .log_signal .emit (" (No links extracted yet or all filtered out in links view)") self .log_signal .emit (" (No links extracted yet or all filtered out in links view)")
else:
self.external_link_queue.clear()
self.external_link_queue.extend(filtered_link_queue)
self._try_process_next_external_link()
if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ()) if self .main_log_output :self .main_log_output .verticalScrollBar ().setValue (self .main_log_output .verticalScrollBar ().maximum ())
def _display_and_schedule_next (self ,link_data ):
post_title ,link_text ,link_url ,platform ,decryption_key =link_data
is_only_links_mode =self .radio_only_links and self .radio_only_links .isChecked ()
if is_only_links_mode :
if post_title != self._current_link_post_title:
if self._current_link_post_title is not None:
separator_html = f'{HTML_PREFIX}<hr style="border: 1px solid #444;">'
self.log_signal.emit(separator_html)
title_html = f'<p style="font-size: 11pt; color: #87CEEB; margin-top: 2px; margin-bottom: 2px;"><b>{html.escape(post_title)}</b></p>'
self.log_signal.emit(HTML_PREFIX + title_html)
self._current_link_post_title = post_title
# Use the "smarter" logic to decide what text to show for the link
cleaned_link_text = link_text.strip()
display_text = ""
if cleaned_link_text and cleaned_link_text.lower() != platform.lower() and cleaned_link_text != link_url:
display_text = cleaned_link_text
else:
try:
path = urlparse(link_url).path
filename = os.path.basename(path)
if filename:
display_text = filename
except Exception:
pass
if not display_text:
display_text = link_url
# Truncate long display text
if len(display_text) > 50:
display_text = display_text[:50].strip() + "..."
# --- NEW: Format the output as requested ---
platform_display = platform.capitalize()
plain_link_info_line = f" {platform_display} - {link_url} - ({display_text})"
if decryption_key:
plain_link_info_line += f" (Decryption Key: {decryption_key})"
self.main_log_output.append(plain_link_info_line)
elif self .show_external_links :
# This part for the secondary log remains unchanged
separator ="-"*45
formatted_link_info = f"{link_text} - {link_url} - {platform}"
if decryption_key:
formatted_link_info += f" (Decryption Key: {decryption_key})"
self._append_to_external_log(formatted_link_info, separator)
self ._is_processing_external_link_queue =False
self ._try_process_next_external_link ()
def _export_links_to_file (self ): def _export_links_to_file (self ):
if not (self .radio_only_links and self .radio_only_links .isChecked ()): if not (self .radio_only_links and self .radio_only_links .isChecked ()):
@@ -2295,7 +2450,10 @@ class DownloaderApp (QWidget ):
_ ,_ ,post_id =extract_post_info (url_text .strip ()) _ ,_ ,post_id =extract_post_info (url_text .strip ())
is_single_post_url =bool (post_id ) is_single_post_url =bool (post_id )
subfolders_enabled =self .use_subfolders_checkbox .isChecked ()if self .use_subfolders_checkbox else False
subfolders_by_known_txt_enabled = getattr(self, 'use_subfolders_checkbox', None) and self.use_subfolders_checkbox.isChecked()
subfolder_per_post_enabled = getattr(self, 'use_subfolder_per_post_checkbox', None) and self.use_subfolder_per_post_checkbox.isChecked()
any_subfolder_option_enabled = subfolders_by_known_txt_enabled or subfolder_per_post_enabled
not_only_links_or_archives_mode =not ( not_only_links_or_archives_mode =not (
(self .radio_only_links and self .radio_only_links .isChecked ())or (self .radio_only_links and self .radio_only_links .isChecked ())or
@@ -2303,7 +2461,7 @@ class DownloaderApp (QWidget ):
(hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ()) (hasattr (self ,'radio_only_audio')and self .radio_only_audio .isChecked ())
) )
should_show_custom_folder =is_single_post_url and subfolders_enabled and not_only_links_or_archives_mode should_show_custom_folder =is_single_post_url and any_subfolder_option_enabled and not_only_links_or_archives_mode
if self .custom_folder_widget : if self .custom_folder_widget :
self .custom_folder_widget .setVisible (should_show_custom_folder ) self .custom_folder_widget .setVisible (should_show_custom_folder )
@@ -2383,7 +2541,7 @@ class DownloaderApp (QWidget ):
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_title_text","Name: Post Title")) self .manga_rename_toggle_button .setText (self ._tr ("manga_style_post_title_text","Name: Post Title"))
elif self .manga_filename_style ==STYLE_ORIGINAL_NAME : elif self .manga_filename_style ==STYLE_ORIGINAL_NAME :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_original_file_text","Name: Original File")) self .manga_rename_toggle_button .setText (self ._tr ("manga_style_date_original_text","Date + Original"))
elif self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING : elif self .manga_filename_style ==STYLE_POST_TITLE_GLOBAL_NUMBERING :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_title_global_num_text","Name: Title+G.Num")) self .manga_rename_toggle_button .setText (self ._tr ("manga_style_title_global_num_text","Name: Title+G.Num"))
@@ -2400,7 +2558,6 @@ class DownloaderApp (QWidget ):
else : else :
self .manga_rename_toggle_button .setText (self ._tr ("manga_style_unknown_text","Name: Unknown Style")) self .manga_rename_toggle_button .setText (self ._tr ("manga_style_unknown_text","Name: Unknown Style"))
self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).") self .manga_rename_toggle_button .setToolTip ("Click to cycle Manga Filename Style (when Manga Mode is active for a creator feed).")
def _toggle_manga_filename_style (self ): def _toggle_manga_filename_style (self ):
@@ -2516,8 +2673,7 @@ class DownloaderApp (QWidget ):
show_date_prefix_input =( show_date_prefix_input =(
manga_mode_effectively_on and manga_mode_effectively_on and
(current_filename_style ==STYLE_DATE_BASED or (current_filename_style ==STYLE_DATE_BASED) and
current_filename_style ==STYLE_ORIGINAL_NAME )and
not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode ) not (is_only_links_mode or is_only_archives_mode or is_only_audio_mode )
) )
if hasattr (self ,'manga_date_prefix_input'): if hasattr (self ,'manga_date_prefix_input'):
@@ -2607,6 +2763,12 @@ class DownloaderApp (QWidget ):
self .file_progress_label .setText ("") self .file_progress_label .setText ("")
def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False): def start_download(self, direct_api_url=None, override_output_dir=None, is_restore=False, is_continuation=False):
if self.active_update_profile:
if not self.new_posts_for_update:
return self._check_for_updates()
else:
return self._start_confirmed_update_download()
self.is_finishing = False self.is_finishing = False
self.downloaded_hash_counts.clear() self.downloaded_hash_counts.clear()
global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER global KNOWN_NAMES, BackendDownloadThread, PostProcessorWorker, extract_post_info, clean_folder_name, MAX_FILE_THREADS_PER_POST_OR_WORKER
@@ -2724,6 +2886,52 @@ class DownloaderApp (QWidget ):
return True return True
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
# Read the setting at the start of the download
self.save_creator_json_enabled_this_session = self.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
profile_processed_ids = set() # Default to an empty set
if self.save_creator_json_enabled_this_session:
# --- CREATOR PROFILE LOGIC ---
creator_name_for_profile = None
if self.is_processing_favorites_queue and self.current_processing_favorite_item_info:
creator_name_for_profile = self.current_processing_favorite_item_info.get('name_for_folder')
else:
creator_key = (service.lower(), str(user_id))
creator_name_for_profile = self.creator_name_cache.get(creator_key)
if not creator_name_for_profile:
creator_name_for_profile = f"{service}_{user_id}"
self.log_signal.emit(f"⚠️ Creator name not in cache. Using '{creator_name_for_profile}' for profile file.")
creator_profile_data = self._setup_creator_profile(creator_name_for_profile, self.session_file_path)
# Get all current UI settings and add them to the profile
current_settings = self._get_current_ui_settings_as_dict(api_url_override=api_url, output_dir_override=effective_output_dir_for_run)
creator_profile_data['settings'] = current_settings
creator_profile_data.setdefault('creator_url', [])
if api_url not in creator_profile_data['creator_url']:
creator_profile_data['creator_url'].append(api_url)
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_name_for_profile, creator_profile_data, self.session_file_path)
self.log_signal.emit(f"✅ Profile for '{creator_name_for_profile}' loaded/created. Settings saved.")
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
# --- END OF PROFILE LOGIC ---
# The rest of this logic runs regardless, but uses the profile data if it was loaded
session_processed_ids = set(processed_post_ids_for_restore)
combined_processed_ids = session_processed_ids.union(profile_processed_ids)
processed_post_ids_for_this_run = list(combined_processed_ids)
use_subfolders = self.use_subfolders_checkbox.isChecked() use_subfolders = self.use_subfolders_checkbox.isChecked()
use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked() use_post_subfolders = self.use_subfolder_per_post_checkbox.isChecked()
compress_images = self.compress_images_checkbox.isChecked() compress_images = self.compress_images_checkbox.isChecked()
@@ -2831,15 +3039,6 @@ class DownloaderApp (QWidget ):
if backend_filter_mode == 'audio': if backend_filter_mode == 'audio':
effective_skip_zip = self.skip_zip_checkbox.isChecked() effective_skip_zip = self.skip_zip_checkbox.isChecked()
if not api_url:
QMessageBox.critical(self, "Input Error", "URL is required.")
return False
service, user_id, post_id_from_url = extract_post_info(api_url)
if not service or not user_id:
QMessageBox.critical(self, "Input Error", "Invalid or unsupported URL format.")
return False
creator_folder_ignore_words_for_run = None creator_folder_ignore_words_for_run = None
is_full_creator_download = not post_id_from_url is_full_creator_download = not post_id_from_url
@@ -3187,7 +3386,7 @@ class DownloaderApp (QWidget ):
'downloaded_hash_counts': self.downloaded_hash_counts, 'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock, 'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'skip_current_file_flag': None, 'skip_current_file_flag': None,
'processed_post_ids': processed_post_ids_for_restore, 'processed_post_ids': processed_post_ids_for_this_run,
'start_offset': start_offset_for_restore, 'start_offset': start_offset_for_restore,
} }
@@ -3232,6 +3431,7 @@ class DownloaderApp (QWidget ):
self.is_paused = False self.is_paused = False
return True return True
def restore_download(self): def restore_download(self):
"""Initiates the download restoration process.""" """Initiates the download restoration process."""
if self._is_download_active(): if self._is_download_active():
@@ -3618,7 +3818,10 @@ class DownloaderApp (QWidget ):
if permanent: if permanent:
self.permanently_failed_files_for_dialog.extend(permanent) self.permanently_failed_files_for_dialog.extend(permanent)
self._update_error_button_count() self._update_error_button_count()
if history_data: self._add_to_history_candidates(history_data)
if history_data:
# This single call now correctly handles both history and profile saving.
self._add_to_history_candidates(history_data)
self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count) self.overall_progress_signal.emit(self.total_posts_to_process, self.processed_posts_count)
@@ -3668,7 +3871,22 @@ class DownloaderApp (QWidget ):
self.log_signal.emit("="*40) self.log_signal.emit("="*40)
def _add_to_history_candidates(self, history_data): def _add_to_history_candidates(self, history_data):
"""Adds processed post data to the history candidates list.""" """Adds processed post data to the history candidates list and updates the creator profile."""
if self.save_creator_json_enabled_this_session:
post_id = history_data.get('post_id')
service = history_data.get('service')
user_id = history_data.get('user_id')
if post_id and service and user_id:
creator_key = (service.lower(), str(user_id))
creator_name = self.creator_name_cache.get(creator_key, f"{service}_{user_id}")
# Load the profile data before using it to prevent NameError
profile_data = self._setup_creator_profile(creator_name, self.session_file_path)
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(creator_name, profile_data, self.session_file_path)
if history_data and len(self.download_history_candidates) < 8: if history_data and len(self.download_history_candidates) < 8:
history_data['download_date_timestamp'] = time.time() history_data['download_date_timestamp'] = time.time()
creator_key = (history_data.get('service','').lower(), str(history_data.get('user_id',''))) creator_key = (history_data.get('service','').lower(), str(history_data.get('user_id','')))
@@ -3864,8 +4082,8 @@ class DownloaderApp (QWidget ):
if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .clear () if hasattr (self ,'remove_from_filename_input'):self .remove_from_filename_input .clear ()
self .character_search_input .clear ();self .thread_count_input .setText ("4");self .radio_all .setChecked (True ); self .character_search_input .clear ();self .thread_count_input .setText ("4");self .radio_all .setChecked (True );
self .skip_zip_checkbox .setChecked (True );self .download_thumbnails_checkbox .setChecked (False ); self .skip_zip_checkbox .setChecked (True );self .download_thumbnails_checkbox .setChecked (False );
self .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (True ); self .compress_images_checkbox .setChecked (False );self .use_subfolders_checkbox .setChecked (False );
self .use_subfolder_per_post_checkbox .setChecked (False );self .use_multithreading_checkbox .setChecked (True ); self .use_subfolder_per_post_checkbox .setChecked (True );self .use_multithreading_checkbox .setChecked (True );
if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False ) if self .favorite_mode_checkbox :self .favorite_mode_checkbox .setChecked (False )
if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setChecked (False ) if hasattr (self ,'scan_content_images_checkbox'):self .scan_content_images_checkbox .setChecked (False )
self .external_links_checkbox .setChecked (False ) self .external_links_checkbox .setChecked (False )
@@ -3950,49 +4168,34 @@ class DownloaderApp (QWidget ):
self ._filter_links_log () self ._filter_links_log ()
def cancel_download_button_action(self): def cancel_download_button_action(self):
self.is_finishing = True """
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 Signals all active download processes to cancel but DOES NOT reset the UI.
self .log_signal .emit ("⚠️ Requesting cancellation of download process (soft reset)...") The UI reset is now handled by the 'download_finished' method.
self._cleanup_temp_files() """
self._clear_session_file() # Clear session file on explicit cancel if self.cancellation_event.is_set():
self.log_signal.emit(" Cancellation is already in progress.")
return
self.log_signal.emit("⚠️ Requesting cancellation of download process...")
self.cancellation_event.set()
# Update UI to "Cancelling" state
self.pause_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
self.progress_label.setText(self._tr("status_cancelling", "Cancelling... Please wait."))
# Signal all active components to stop
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.log_signal.emit(" Signaling worker pool to cancel futures...")
if self.external_link_download_thread and self.external_link_download_thread.isRunning(): if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.log_signal.emit(" Cancelling active External Link download thread...") self.log_signal.emit(" Cancelling active External Link download thread...")
self.external_link_download_thread.cancel() self.external_link_download_thread.cancel()
current_url =self .link_input .text ()
current_dir =self .dir_input .text ()
self .cancellation_event .set ()
self .is_fetcher_thread_running =False
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 .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 =[]
self .external_link_queue .clear ();self ._is_processing_external_link_queue =False ;self ._current_link_post_title =None
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 .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."""
if not isinstance (service_name ,str ): if not isinstance (service_name ,str ):
@@ -4009,10 +4212,23 @@ class DownloaderApp (QWidget ):
return return
self.is_finishing = True self.is_finishing = True
try:
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.") self.log_signal.emit("🏁 Download of current item complete.")
if self.is_processing_favorites_queue and self.favorite_download_queue: if self.is_processing_favorites_queue and self.favorite_download_queue:
self.log_signal.emit("✅ Item finished. Processing next in queue...") self.log_signal.emit("✅ Item finished. Processing next in queue...")
self.is_finishing = False # Allow the next item in queue to start
self._process_next_favorite_download() self._process_next_favorite_download()
return return
@@ -4026,10 +4242,7 @@ class DownloaderApp (QWidget ):
self.is_restore_pending = False self.is_restore_pending = False
self._finalize_download_history() 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") status_message = self._tr("status_completed", "Completed")
if cancelled_by_user and self.retryable_failed_files_info:
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()
summary_log = "=" * 40 summary_log = "=" * 40
summary_log += f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n" summary_log += f"\n🏁 Download {status_message}!\n Summary: Downloaded Files={total_downloaded}, Skipped Files={total_skipped}\n"
@@ -4038,17 +4251,14 @@ class DownloaderApp (QWidget ):
self.log_signal.emit("") self.log_signal.emit("")
if self.thread_pool: if self.thread_pool:
self.log_signal.emit(" Shutting down worker thread pool...")
self.thread_pool.shutdown(wait=False) self.thread_pool.shutdown(wait=False)
self.thread_pool = None self.thread_pool = None
self.log_signal.emit(" Thread pool shut down.")
if self.single_pdf_setting and self.session_temp_files and not cancelled_by_user: if self.single_pdf_setting and self.session_temp_files:
try: try:
self._trigger_single_pdf_creation() self._trigger_single_pdf_creation()
finally: finally:
self._cleanup_temp_files() self._cleanup_temp_files()
self.single_pdf_setting = False
else: else:
self._cleanup_temp_files() self._cleanup_temp_files()
self.single_pdf_setting = False self.single_pdf_setting = False
@@ -4106,8 +4316,9 @@ class DownloaderApp (QWidget ):
"Would you like to attempt to download these failed files again?", "Would you like to attempt to download these failed files again?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
self.is_finishing = False # Allow retry session to start
self._start_failed_files_retry_session() self._start_failed_files_retry_session()
return return # Exit to allow retry session to run
else: else:
self.log_signal.emit(" User chose not to retry failed files.") self.log_signal.emit(" User chose not to retry failed files.")
self.permanently_failed_files_for_dialog.extend(self.retryable_failed_files_info) self.permanently_failed_files_for_dialog.extend(self.retryable_failed_files_info)
@@ -4121,6 +4332,9 @@ class DownloaderApp (QWidget ):
self.set_ui_enabled(True) self.set_ui_enabled(True)
self._update_button_states_and_connections() self._update_button_states_and_connections()
self.cancellation_message_logged_this_session = False self.cancellation_message_logged_this_session = False
self.active_update_profile = None
finally:
self.is_finishing = False
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."""
@@ -4372,52 +4586,37 @@ class DownloaderApp (QWidget ):
if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:")) if self .progress_log_label :self .progress_log_label .setText (self ._tr ("progress_log_label_text","📜 Progress Log:"))
def reset_application_state(self): def reset_application_state(self):
if self._is_download_active(): self.log_signal.emit("🔄 Resetting application state to defaults...")
if self.download_thread and self.download_thread.isRunning():
self.log_signal.emit("⚠️ Cancelling active download thread for reset...") # --- MODIFIED PART: Signal all threads to stop first, but do not wait ---
self.cancellation_event.set() if self._is_download_active():
self.download_thread.requestInterruption() self.log_signal.emit(" Cancelling all active background tasks for reset...")
self.download_thread.wait(3000) self.cancellation_event.set() # Signal all threads to stop
if self.download_thread.isRunning():
self.log_signal.emit(" ⚠️ Download thread did not terminate gracefully.") # Initiate non-blocking shutdowns
self.download_thread.deleteLater() if self.download_thread and self.download_thread.isRunning():
self.download_thread = None self.download_thread.requestInterruption()
if self.thread_pool: if self.thread_pool:
self.log_signal.emit(" Shutting down thread pool for reset...") self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.thread_pool.shutdown(wait=True, cancel_futures=True) self.thread_pool = None
self.thread_pool = None if self.external_link_download_thread and self.external_link_download_thread.isRunning():
self.active_futures = [] self.external_link_download_thread.cancel()
if self.external_link_download_thread and self.external_link_download_thread.isRunning(): if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Cancelling external link download thread for reset...") self.retry_thread_pool.shutdown(wait=False, cancel_futures=True)
self.external_link_download_thread.cancel() self.retry_thread_pool = None
self.external_link_download_thread.wait(3000)
self.external_link_download_thread.deleteLater() self.cancellation_event.clear() # Reset the event for the next run
self.external_link_download_thread = None # --- END OF MODIFIED PART ---
if hasattr(self, 'retry_thread_pool') and self.retry_thread_pool:
self.log_signal.emit(" Shutting down retry thread pool for reset...")
self.retry_thread_pool.shutdown(wait=True)
self.retry_thread_pool = None
if hasattr(self, 'active_retry_futures'):
self.active_retry_futures.clear()
if hasattr(self, 'active_retry_futures_map'):
self.active_retry_futures_map.clear()
self.cancellation_event.clear()
if self.pause_event: if self.pause_event:
self.pause_event.clear() self.pause_event.clear()
self.is_paused = False self.is_paused = False
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._clear_session_file() self._clear_session_file()
self._reset_ui_to_defaults() self._reset_ui_to_defaults()
self._load_saved_download_location() self._load_saved_download_location()
self.main_log_output.clear() self.main_log_output.clear()
self.external_log_output.clear() self.external_log_output.clear()
self.log_signal.emit("🔄 Resetting application state to defaults...")
self._reset_ui_to_defaults()
self._load_saved_download_location()
self.main_log_output.clear()
self.external_log_output.clear()
if self.missed_character_log_output: if self.missed_character_log_output:
self.missed_character_log_output.clear() self.missed_character_log_output.clear()
@@ -4464,6 +4663,7 @@ class DownloaderApp (QWidget ):
self.interrupted_session_data = None self.interrupted_session_data = None
self.is_restore_pending = False self.is_restore_pending = False
self.active_update_profile = None
self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style) self.settings.setValue(MANGA_FILENAME_STYLE_KEY, self.manga_filename_style)
self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope) self.settings.setValue(SKIP_WORDS_SCOPE_KEY, self.skip_words_scope)
self.settings.sync() self.settings.sync()
@@ -4498,8 +4698,8 @@ class DownloaderApp (QWidget ):
self.skip_zip_checkbox.setChecked(True) self.skip_zip_checkbox.setChecked(True)
self.download_thumbnails_checkbox.setChecked(False) self.download_thumbnails_checkbox.setChecked(False)
self.compress_images_checkbox.setChecked(False) self.compress_images_checkbox.setChecked(False)
self.use_subfolders_checkbox.setChecked(True) self.use_subfolders_checkbox.setChecked(False)
self.use_subfolder_per_post_checkbox.setChecked(False) self.use_subfolder_per_post_checkbox.setChecked(True)
self.use_multithreading_checkbox.setChecked(True) self.use_multithreading_checkbox.setChecked(True)
if self.favorite_mode_checkbox: if self.favorite_mode_checkbox:
self.favorite_mode_checkbox.setChecked(False) self.favorite_mode_checkbox.setChecked(False)
@@ -4773,6 +4973,144 @@ class DownloaderApp (QWidget ):
self ._update_favorite_scope_button_text () self ._update_favorite_scope_button_text ()
self .log_signal .emit (f" Favorite download scope changed to: '{self .favorite_download_scope }'") self .log_signal .emit (f" Favorite download scope changed to: '{self .favorite_download_scope }'")
def _check_for_updates(self):
"""Phase 1 of Update: Fetches all posts, compares, and prompts the user for confirmation."""
self.log_signal.emit("🔄 Checking for updates...")
update_url = self.active_update_profile['creator_url'][0]
processed_ids_from_profile = set(self.active_update_profile['processed_post_ids'])
self.log_signal.emit(f" Checking URL: {update_url}")
self.set_ui_enabled(False)
self.progress_label.setText(self._tr("progress_fetching_all_posts", "Progress: Fetching all post pages..."))
QCoreApplication.processEvents()
try:
post_generator = download_from_api(
api_url_input=update_url,
logger=lambda msg: None, # Suppress noisy logs during check
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=self.use_cookie_checkbox.isChecked(),
cookie_text=self.cookie_text_input.text(),
selected_cookie_file=self.selected_cookie_filepath,
app_base_dir=self.app_base_dir,
processed_post_ids=processed_ids_from_profile
)
all_posts_from_api = [post for batch in post_generator for post in batch]
except Exception as e:
self.log_signal.emit(f"❌ Failed to fetch posts during update check: {e}")
self.download_finished(0, 0, False, [])
return
self.log_signal.emit(f" Fetched a total of {len(all_posts_from_api)} posts from the server.")
self.new_posts_for_update = [post for post in all_posts_from_api if post.get('id') not in processed_ids_from_profile]
if not self.new_posts_for_update:
self.log_signal.emit("✅ Creator is up to date! No new posts found.")
QMessageBox.information(self, "Up to Date", "No new posts were found for this creator.")
self._clear_update_selection()
self.set_ui_enabled(True)
self.progress_label.setText(self._tr("progress_idle_text", "Progress: Idle"))
return
self.log_signal.emit(f" Found {len(self.new_posts_for_update)} new post(s). Waiting for user confirmation to download.")
self.progress_label.setText(f"Found {len(self.new_posts_for_update)} new post(s). Ready to download.")
self._update_button_states_and_connections()
def _start_confirmed_update_download(self):
"""Phase 2 of Update: Starts the download of posts found during the check."""
self.log_signal.emit(f"✅ User confirmed. Starting download for {len(self.new_posts_for_update)} new post(s).")
self.main_log_output.clear()
from src.config.constants import FOLDER_NAME_STOP_WORDS
update_url = self.active_update_profile['creator_url'][0]
service, user_id, _ = extract_post_info(update_url)
# --- FIX: Use the BASE download path, not the creator-specific one ---
# Get the base path from the UI (e.g., "E:/Kemono"). The worker will create subfolders inside this.
base_download_dir_from_ui = self.dir_input.text().strip()
self.log_signal.emit(f" Update session will save to base folder: {base_download_dir_from_ui}")
# --- END FIX ---
raw_character_filters_text = self.character_input.text().strip()
parsed_character_filter_objects = self._parse_character_filters(raw_character_filters_text)
# --- FIX: Set paths to mimic a normal download, allowing the worker to create subfolders ---
# 'download_root' is the base directory.
# 'override_output_dir' is None, which allows the worker to use its own folder logic.
args_template = {
'api_url_input': update_url,
'download_root': base_download_dir_from_ui, # Corrected: Use the BASE path
'override_output_dir': None, # Corrected: Set to None to allow subfolder logic
'known_names': list(KNOWN_NAMES),
'filter_character_list': parsed_character_filter_objects,
'emitter': self.worker_to_gui_queue,
'unwanted_keywords': FOLDER_NAME_STOP_WORDS,
'filter_mode': self.get_filter_mode(),
'skip_zip': self.skip_zip_checkbox.isChecked(),
'use_subfolders': self.use_subfolders_checkbox.isChecked(),
'use_post_subfolders': self.use_subfolder_per_post_checkbox.isChecked(),
'target_post_id_from_initial_url': None,
'custom_folder_name': self.custom_folder_input.text().strip(),
'compress_images': self.compress_images_checkbox.isChecked(),
'download_thumbnails': self.download_thumbnails_checkbox.isChecked(),
'service': service, 'user_id': user_id, 'pause_event': self.pause_event,
'cancellation_event': self.cancellation_event,
'downloaded_files': self.downloaded_files,
'downloaded_file_hashes': self.downloaded_file_hashes,
'downloaded_files_lock': self.downloaded_files_lock,
'downloaded_file_hashes_lock': self.downloaded_file_hashes_lock,
'dynamic_character_filter_holder': self.dynamic_character_filter_holder,
'skip_words_list': [word.strip().lower() for word in self.skip_words_input.text().strip().split(',') if word.strip()],
'skip_words_scope': self.get_skip_words_scope(),
'show_external_links': self.external_links_checkbox.isChecked(),
'extract_links_only': self.radio_only_links.isChecked(),
'skip_current_file_flag': None,
'manga_mode_active': self.manga_mode_checkbox.isChecked(),
'manga_filename_style': self.manga_filename_style,
'char_filter_scope': self.get_char_filter_scope(),
'remove_from_filename_words_list': [word.strip() for word in self.remove_from_filename_input.text().strip().split(',') if word.strip()],
'allow_multipart_download': self.allow_multipart_download_setting,
'cookie_text': self.cookie_text_input.text(),
'use_cookie': self.use_cookie_checkbox.isChecked(),
'selected_cookie_file': self.selected_cookie_filepath,
'app_base_dir': self.app_base_dir,
'manga_date_prefix': self.manga_date_prefix_input.text().strip(),
'manga_date_file_counter_ref': None,
'scan_content_for_images': self.scan_content_images_checkbox.isChecked(),
'creator_download_folder_ignore_words': None,
'manga_global_file_counter_ref': None,
'use_date_prefix_for_subfolder': self.date_prefix_checkbox.isChecked(),
'keep_in_post_duplicates': self.keep_duplicates_checkbox.isChecked(),
'keep_duplicates_mode': self.keep_duplicates_mode,
'keep_duplicates_limit': self.keep_duplicates_limit,
'downloaded_hash_counts': self.downloaded_hash_counts,
'downloaded_hash_counts_lock': self.downloaded_hash_counts_lock,
'session_file_path': self.session_file_path,
'session_lock': self.session_lock,
'text_only_scope': self.more_filter_scope,
'text_export_format': self.text_export_format,
'single_pdf_mode': self.single_pdf_setting,
'project_root_dir': self.app_base_dir,
'processed_post_ids': list(self.active_update_profile['processed_post_ids'])
}
num_threads = int(self.thread_count_input.text()) if self.use_multithreading_checkbox.isChecked() else 1
self.thread_pool = ThreadPoolExecutor(max_workers=num_threads, thread_name_prefix='UpdateWorker_')
self.total_posts_to_process = len(self.new_posts_for_update)
self.processed_posts_count = 0
self.overall_progress_signal.emit(self.total_posts_to_process, 0)
ppw_expected_keys = list(PostProcessorWorker.__init__.__code__.co_varnames)[1:]
for post_data in self.new_posts_for_update:
self._submit_post_to_worker_pool(
post_data, args_template, 1, self.worker_to_gui_queue, ppw_expected_keys, {}
)
return True
def _show_empty_popup (self ): def _show_empty_popup (self ):
"""Creates and shows the empty popup dialog.""" """Creates and shows the empty popup dialog."""
if self.is_restore_pending: if self.is_restore_pending:
@@ -4782,7 +5120,21 @@ class DownloaderApp (QWidget ):
return return
dialog = EmptyPopupDialog(self.app_base_dir, self) dialog = EmptyPopupDialog(self.app_base_dir, self)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
if hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue: if dialog.update_profile_data:
self.active_update_profile = dialog.update_profile_data
self.link_input.setText(dialog.update_creator_name)
self.favorite_download_queue.clear()
if 'settings' in self.active_update_profile:
self.log_signal.emit(f" Applying saved settings from '{dialog.update_creator_name}' profile...")
self._load_ui_from_settings_dict(self.active_update_profile['settings'])
self.log_signal.emit(" Settings restored.")
self.log_signal.emit(f" Loaded profile for '{dialog.update_creator_name}'. Click 'Check For Updates' to continue.")
self._update_button_states_and_connections()
elif hasattr(dialog, 'selected_creators_for_queue') and dialog.selected_creators_for_queue:
self.active_update_profile = None
self.favorite_download_queue.clear() self.favorite_download_queue.clear()
for creator_data in dialog.selected_creators_for_queue: for creator_data in dialog.selected_creators_for_queue:

View File

@@ -239,16 +239,23 @@ def setup_ui(main_app):
checkboxes_group_layout.addWidget(advanced_settings_label) checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout() advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10) advanced_row1_layout.setSpacing(10)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(True) # --- REORDERED CHECKBOXES ---
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post") main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders) main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
main_app.use_subfolder_per_post_checkbox.setChecked(True)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox) advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix") main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.") main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox) advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(False)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- END REORDER ---
main_app.use_cookie_checkbox = QCheckBox("Use Cookie") main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting) main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit() main_app.cookie_text_input = QLineEdit()
@@ -380,7 +387,7 @@ def setup_ui(main_app):
main_app.link_search_input.setPlaceholderText("Search Links...") main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False) main_app.link_search_input.setVisible(False)
log_title_layout.addWidget(main_app.link_search_input) log_title_layout.addWidget(main_app.link_search_input)
main_app.link_search_button = QPushButton("🔍") main_app.link_search_button = QPushButton("<EFBFBD>")
main_app.link_search_button.setVisible(False) main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale)) main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button) log_title_layout.addWidget(main_app.link_search_button)