mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9db89cfad0 | ||
|
|
0a6034a632 | ||
|
|
2da69e7017 | ||
|
|
3209770d00 | ||
|
|
337cdd342c | ||
|
|
d54b013bbc | ||
|
|
2785fc1121 | ||
|
|
fbdae61b80 | ||
|
|
33133eb275 | ||
|
|
3935cbeea4 |
24
LICENSE
24
LICENSE
@@ -1,11 +1,21 @@
|
||||
Custom License - No Commercial Use
|
||||
MIT License
|
||||
|
||||
Copyright [Yuvi9587] [2025]
|
||||
Copyright (c) [2025] [Yuvi9587]
|
||||
|
||||
Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for **non-commercial purposes only**, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
2. Proper credit must be given to the original author in any public use, distribution, or derivative works.
|
||||
3. Commercial use, resale, or sublicensing of the Software or any derivative works is strictly prohibited without explicit written permission.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND...
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
339
features.md
339
features.md
@@ -1,192 +1,147 @@
|
||||
# Kemono Downloader - Feature Guide
|
||||
This guide provides a comprehensive overview of all user interface elements, input fields, buttons, popups, and functionalities available in the Kemono Downloader.
|
||||
|
||||
## 1. Main Interface & Workflow
|
||||
These are the primary controls you'll interact with to initiate and manage downloads.
|
||||
|
||||
### 1.1. Core Inputs
|
||||
**🔗 Creator/Post URL Input Field**
|
||||
- **Purpose**: Paste the URL of the content you want to download.
|
||||
- **Supported Sites**: Kemono.su, Coomer.party, Simpcity.su.
|
||||
- **Supported URL Types**:
|
||||
- Creator pages (e.g., `https://kemono.su/patreon/user/12345`).
|
||||
- Individual posts (e.g., `https://kemono.su/patreon/user/12345/post/98765`).
|
||||
- **Note**: When ⭐ Favorite Mode is active, this field is disabled. For Simpcity.su URLs, the "Use Cookie" option is mandatory and auto-enabled.
|
||||
|
||||
**🎨 Creator Selection Button**
|
||||
- **Icon**: 🎨 (Artist Palette)
|
||||
- **Purpose**: Opens the "Creator Selection" dialog to browse and queue downloads from known creators.
|
||||
- **Dialog Features**:
|
||||
- Loads creators from `creators.json`.
|
||||
- **Search Bar**: Filter creators by name.
|
||||
- **Creator List**: Displays creators with their service (e.g., Patreon, Fanbox).
|
||||
- **Selection**: Checkboxes to select one or more creators.
|
||||
- **Download Scope**: Organize downloads by Characters or Creators.
|
||||
- **Add to Queue**: Adds selected creators or their posts to the download queue.
|
||||
|
||||
**Page Range (Start to End) Input Fields**
|
||||
- **Purpose**: Specify a range of pages to fetch for creator URLs.
|
||||
- **Usage**: Enter the starting and ending page numbers.
|
||||
- **Behavior**:
|
||||
- If blank, all pages are processed.
|
||||
- Disabled for single post URLs.
|
||||
|
||||
**📁 Download Location Input Field & Browse Button**
|
||||
- **Purpose**: Specify the main directory for downloaded files.
|
||||
- **Usage**: Type the path or click "Browse..." to select a folder.
|
||||
- **Requirement**: Mandatory for all download operations.
|
||||
|
||||
### 1.2. Action Buttons
|
||||
**⬇️ Start Download / 🔗 Extract Links Button**
|
||||
- **Purpose**: Initiates downloading or link extraction.
|
||||
- **Behavior**:
|
||||
- Shows "🔗 Extract Links" if "Only Links" is selected.
|
||||
- Otherwise, shows "⬇️ Start Download".
|
||||
- Supports single-threaded or multi-threaded downloads based on settings.
|
||||
|
||||
**🔄 Restore Download Button**
|
||||
- **Visibility**: Appears if an incomplete session is detected on startup.
|
||||
- **Purpose**: Resumes a previously interrupted download session.
|
||||
|
||||
**⏸️ Pause / ▶️ Resume Download Button**
|
||||
- **Purpose**: Pause or resume the ongoing download.
|
||||
- **Behavior**: Toggles between "Pause" and "Resume". Some UI settings can be changed while paused.
|
||||
|
||||
**❌ Cancel & Reset UI Button**
|
||||
- **Purpose**: Stops the current operation and performs a "soft" reset.
|
||||
- **Behavior**: Halts background threads, preserves URL and Download Location inputs, resets other settings.
|
||||
|
||||
**🔄 Reset Button (in the log area)**
|
||||
- **Purpose**: Performs a "hard" reset when no operation is active.
|
||||
- **Behavior**: Clears all inputs, resets options to default, and clears logs.
|
||||
|
||||
## 2. Filtering & Content Selection
|
||||
These options allow precise control over downloaded content.
|
||||
|
||||
### 2.1. Content Filtering
|
||||
**🎯 Filter by Character(s) Input Field**
|
||||
- **Purpose**: Download content related to specific characters or series.
|
||||
- **Usage**: Enter comma-separated character names.
|
||||
- **Advanced Syntax**:
|
||||
- `Nami`: Simple filter.
|
||||
- `(Vivi, Ulti)`: Grouped filter. Matches posts with "Vivi" OR "Ulti". Creates a shared folder like `Vivi Ulti` if subfolders are enabled.
|
||||
- `(Boa, Hancock)~`: Aliased filter. Treats "Boa" and "Hancock" as the same entity.
|
||||
|
||||
**Filter: [Type] Button (Character Filter Scope)**
|
||||
- **Purpose**: Defines where the character filter is applied. Cycles on click.
|
||||
- **Options**:
|
||||
- **Filter: Title** (Default): Matches post titles.
|
||||
- **Filter: Files**: Matches filenames.
|
||||
- **Filter: Both**: Checks title first, then filenames.
|
||||
- **Filter: Comments (Beta)**: Checks filenames, then post comments.
|
||||
|
||||
**🚫 Skip with Words Input Field**
|
||||
- **Purpose**: Exclude posts/files with specified keywords (e.g., `WIP`, `sketch`).
|
||||
|
||||
**Scope: [Type] Button (Skip Words Scope)**
|
||||
- **Purpose**: Defines where skip words are applied. Cycles on click.
|
||||
- **Options**:
|
||||
- **Scope: Posts** (Default): Skips posts if the title contains a skip word.
|
||||
- **Scope: Files**: Skips files if the filename contains a skip word.
|
||||
- **Scope: Both**: Applies both rules.
|
||||
|
||||
**✂️ Remove Words from Name Input Field**
|
||||
- **Purpose**: Remove unwanted text from filenames (e.g., `patreon`, `[HD]`).
|
||||
|
||||
### 2.2. File Type Filtering
|
||||
**Filter Files (Radio Buttons)**
|
||||
- **Purpose**: Select file types to download.
|
||||
- **Options**:
|
||||
- **All**: All file types.
|
||||
- **Images/GIFs**: Common image formats.
|
||||
- **Videos**: Common video formats.
|
||||
- **🎧 Only Audio**: Common audio formats.
|
||||
- **📦 Only Archives**: Only `.zip` and `.rar` files.
|
||||
- **🔗 Only Links**: Extracts external links without downloading files.
|
||||
|
||||
**Skip .zip / Skip .rar Checkboxes**
|
||||
- **Purpose**: Skip downloading `.zip` or `.rar` files.
|
||||
- **Behavior**: Disabled when "📦 Only Archives" is active.
|
||||
|
||||
## 3. Download Customization
|
||||
Options to refine the download process and output.
|
||||
|
||||
- **Download Thumbnails Only**: Downloads small preview images instead of full-resolution files.
|
||||
- **Scan Content for Images**: Scans post HTML for `<img>` tags, crucial for images in descriptions.
|
||||
- **Compress to WebP**: Converts images to WebP format (requires Pillow library).
|
||||
- **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`).
|
||||
- **🗄️ Custom Folder Name (Single Post Only)**: Specify a custom folder name for a single post's content (appears if subfolders are enabled).
|
||||
|
||||
## 4. 📖 Manga/Comic Mode
|
||||
A mode for downloading creator feeds in chronological order, ideal for sequential content.
|
||||
|
||||
- **Activation**: Active when downloading a creator's entire feed (not a single post).
|
||||
- **Core Behavior**: Fetches all posts, processing from oldest to newest.
|
||||
- **Filename Style Toggle Button (in the log area)**:
|
||||
- **Purpose**: Controls file naming in Manga Mode. Cycles on click.
|
||||
- **Options**:
|
||||
- **Name: Post Title**: First file named after post title; others keep original names.
|
||||
- **Name: Original File**: Files keep server-provided names, with optional prefix.
|
||||
- **Name: Title+G.Num**: Global numbering with post title prefix (e.g., `Chapter 1_001.jpg`).
|
||||
- **Name: Date Based**: Sequential naming by post date (e.g., `001.jpg`), with optional prefix.
|
||||
- **Name: Post ID**: Files named after post ID to avoid clashes.
|
||||
- **Name: Date + Title**: Combines post date and title for filenames.
|
||||
|
||||
## 5. Folder Organization & Known.txt
|
||||
Controls for structuring downloaded content.
|
||||
|
||||
- **Separate Folders by Name/Title Checkbox**: Enables automatic subfolder creation.
|
||||
- **Subfolder per Post Checkbox**: Creates subfolders for each post, named after the post title.
|
||||
- **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.
|
||||
- **Known.txt Management UI (Bottom Left)**:
|
||||
- **Purpose**: Manages a local `Known.txt` file for series, characters, or terms used in folder creation.
|
||||
- **List Display**: Shows primary names from `Known.txt`.
|
||||
- **➕ Add Button**: Adds names or groups (e.g., `(Character A, Alias B)~`).
|
||||
- **⤵️ Add to Filter Button**: Select names from `Known.txt` for the character filter.
|
||||
- **🗑️ Delete Selected Button**: Removes selected names from `Known.txt`.
|
||||
- **Open Known.txt Button**: Opens the file in the default text editor.
|
||||
- **❓ Help Button**: Opens this feature guide.
|
||||
- **📜 History Button**: Views recent download history.
|
||||
|
||||
## 6. ⭐ Favorite Mode (Kemono.su Only)
|
||||
Download from favorited artists/posts on Kemono.su.
|
||||
|
||||
- **Enable Checkbox ("⭐ Favorite Mode")**:
|
||||
- Switches to Favorite Mode.
|
||||
- Disables the main URL input.
|
||||
- Changes action buttons to "Favorite Artists" and "Favorite Posts".
|
||||
- Requires cookies.
|
||||
- **🖼️ Favorite Artists Button**: Select and download from favorited artists.
|
||||
- **📄 Favorite Posts Button**: Select and download specific favorited posts.
|
||||
- **Favorite Download Scope Button**:
|
||||
- **Scope: Selected Location**: Downloads favorites to the main directory.
|
||||
- **Scope: Artist Folders**: Creates subfolders per artist.
|
||||
|
||||
## 7. Advanced Settings & Performance
|
||||
- **🍪 Cookie Management**:
|
||||
- **Use Cookie Checkbox**: Enables cookies for restricted content.
|
||||
- **Cookie Text Field**: Paste cookie string.
|
||||
- **Browse... Button**: Select a `cookies.txt` file (Netscape format).
|
||||
- **Use Multithreading Checkbox & Threads Input**:
|
||||
- **Purpose**: Configures simultaneous operations.
|
||||
- **Behavior**: Sets concurrent post processing (creator feeds) or file downloads (single posts).
|
||||
- **Multi-part Download Toggle Button**:
|
||||
- **Purpose**: Enables/disables multi-segment downloading for large files.
|
||||
- **Note**: Best for large files; less efficient for small files.
|
||||
|
||||
## 8. Logging, Monitoring & Error Handling
|
||||
- **📜 Progress Log Area**: Displays messages, progress, and errors.
|
||||
- **👁️ / 🙈 Log View Toggle Button**: Switches between Progress Log and Missed Character Log (skipped posts).
|
||||
- **Show External Links in Log**: Displays external links (e.g., Mega, Google Drive) in a secondary panel.
|
||||
- **Export Links Button**: Saves extracted links to a `.txt` file in "Only Links" mode.
|
||||
- **Download Extracted Links Button**: Downloads files from supported external links in "Only Links" mode.
|
||||
- **🆘 Error Button & Dialog**:
|
||||
- **Purpose**: Active if files fail to download. The button will display a live count of failed files (e.g., **(3) Error**).
|
||||
- **Dialog Features**:
|
||||
- Lists failed files.
|
||||
- Retry failed downloads.
|
||||
- Export failed URLs to a text file.
|
||||
|
||||
## 9. Application Settings (⚙️)
|
||||
- **Appearance**: Switch between Light and Dark themes.
|
||||
- **Language**: Change UI language (restart required).
|
||||
<div>
|
||||
<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>
|
||||
<hr>
|
||||
<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>
|
||||
<h3><strong>Primary Inputs (Top-Left)</strong></h3>
|
||||
<ul>
|
||||
<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>
|
||||
<li><strong>🎨 Creator Selection Popup</strong>: This button opens a powerful dialog listing all known creators. From here, you can:
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Download Location</strong>: The primary folder where all content will be saved. The <strong>Browse...</strong> button lets you select this folder from your computer.</li>
|
||||
<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>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Filtering & Naming (Left Panel)</strong></h2>
|
||||
<p>These features give you precise control over what gets downloaded and how it's named and organized.</p>
|
||||
<ul>
|
||||
<li><strong>Filter by Character(s)</strong>: A powerful tool to download content featuring specific characters. You can enter multiple names separated by commas.
|
||||
<ul>
|
||||
<li><strong>Filter: [Scope] Button</strong>: This button changes how the character filter works:
|
||||
<ul>
|
||||
<li><strong>Title</strong>: Downloads posts only if a character's name is in the post title.</li>
|
||||
<li><strong>Files</strong>: Downloads posts if a character's name is in any of the filenames within the post.</li>
|
||||
<li><strong>Both</strong>: Combines the "Title" and "Files" logic.</li>
|
||||
<li><strong>Comments (Beta)</strong>: Downloads a post if a character's name is mentioned in the comments section.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Skip with Words</strong>: A keyword-based filter to avoid unwanted content (e.g., <code>WIP</code>, <code>sketch</code>).
|
||||
<ul>
|
||||
<li><strong>Scope: [Type] Button</strong>: This button changes how the skip filter works:
|
||||
<ul>
|
||||
<li><strong>Posts</strong>: Skips the entire post if a keyword is found in the title.</li>
|
||||
<li><strong>Files</strong>: Skips only individual files if a keyword is found in the filename.</li>
|
||||
<li><strong>Both</strong>: Applies both levels of skipping.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Remove Words from name</strong>: Automatically cleans downloaded filenames by removing any specified words (e.g., "patreon," "HD").</li>
|
||||
</ul>
|
||||
<h3><strong>File Type Filter (Radio Buttons)</strong></h3>
|
||||
<p>This section lets you choose the kind of content you want:</p>
|
||||
<ul>
|
||||
<li><strong>All, Images/GIFs, Videos, 🎧 Only Audio, 📦 Only Archives</strong>: These options filter the downloads to only include the selected file types.</li>
|
||||
<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>
|
||||
<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>
|
||||
<hr>
|
||||
<h2><strong>Download Options & Advanced Settings (Checkboxes)</strong></h2>
|
||||
<ul>
|
||||
<li><strong>Skip .zip</strong>: A simple toggle to ignore archive files during downloads.</li>
|
||||
<li><strong>Download Thumbnails Only</strong>: Downloads only the small preview images instead of the full-resolution files.</li>
|
||||
<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>
|
||||
<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>
|
||||
<li><strong>Subfolder per Post</strong>: Organizes downloads by creating a unique folder for each post, named after the post's title.</li>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Known Names Management (Bottom-Left)</strong></h2>
|
||||
<p>This powerful feature automates the creation of organized, named folders.</p>
|
||||
<ul>
|
||||
<li><strong>Known Shows/Characters List</strong>: Displays all the names and groups you've saved.</li>
|
||||
<li><strong>Search...</strong>: Filters the list to quickly find a name.</li>
|
||||
<li><strong>Open Known.txt</strong>: Opens the source file in a text editor for advanced manual editing.</li>
|
||||
<li><strong>Add New Name</strong>:
|
||||
<ul>
|
||||
<li><strong>Single Name</strong>: Typing <code>Tifa Lockhart</code> and clicking <strong>➕ Add</strong> creates an entry that will match "Tifa Lockhart".</li>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>⤵️ Add to Filter</strong>: Opens a dialog with your full Known Names list, allowing you to check multiple entries and add them all to the "Filter by Character(s)" field at once.</li>
|
||||
<li><strong>🗑️ Delete Selected</strong>: Removes highlighted names from your list.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Action Buttons & Status Controls</strong></h2>
|
||||
<ul>
|
||||
<li><strong>⬇️ Start Download / 🔗 Extract Links</strong>: The main action button. Its function is dynamic:
|
||||
<ul>
|
||||
<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>
|
||||
<li><strong>Update Confirmation</strong>: After new posts are found, it changes to <strong>⬇️ Start Download (X new)</strong>.</li>
|
||||
<li><strong>Link Extraction Mode</strong>: The text changes to <strong>🔗 Extract Links</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<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>
|
||||
<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>
|
||||
<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:
|
||||
<ul>
|
||||
<li>Select specific files to <strong>Retry</strong> downloading.</li>
|
||||
<li><strong>Export</strong> the list of failed URLs to a <code>.txt</code> file.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<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>
|
||||
<li><strong>📜 (History)</strong>: Opens the Download History dialog.</li>
|
||||
<li><strong>? (Help)</strong>: Opens a helpful guide explaining the application's features.</li>
|
||||
<li><strong>❤️ Support</strong>: Opens a dialog with information on how to support the developer.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2><strong>Specialized Modes & Features</strong></h2>
|
||||
<h3><strong>⭐ Favorite Mode</strong></h3>
|
||||
<p>Activating this mode transforms the UI for managing saved collections:</p>
|
||||
<ul>
|
||||
<li>The URL input is disabled.</li>
|
||||
<li>The main action buttons are replaced with:
|
||||
<ul>
|
||||
<li><strong>🖼️ Favorite Artists</strong>: Opens a dialog to browse and queue downloads from your saved favorite creators.</li>
|
||||
<li><strong>📄 Favorite Posts</strong>: Opens a dialog to browse and queue downloads for specific saved favorite posts.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Scope: [Location] Button</strong>: Toggles where the favorited content is saved:
|
||||
<ul>
|
||||
<li><strong>Selected Location</strong>: Saves all content directly into the main "Download Location".</li>
|
||||
<li><strong>Artist Folders</strong>: Creates a subfolder for each artist inside the main "Download Location".</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h3><strong>📖 Manga/Comic Mode</strong></h3>
|
||||
<p>This mode is designed for sequential content and has several effects:</p>
|
||||
<ul>
|
||||
<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>
|
||||
<li><strong>Disables Multithreading (for certain styles)</strong>: To guarantee perfect sequential numbering, multithreading for posts is automatically disabled for certain naming styles.</li>
|
||||
</ul>
|
||||
<h3><strong>Session & Error Management</strong></h3>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
<h3><strong>Logging & Monitoring</strong></h3>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,7 @@ LANGUAGE_KEY = "currentLanguageV1"
|
||||
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
|
||||
RESOLUTION_KEY = "window_resolution"
|
||||
UI_SCALE_KEY = "ui_scale_factor"
|
||||
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
|
||||
|
||||
# --- UI Constants and Identifiers ---
|
||||
HTML_PREFIX = "<!HTML!>"
|
||||
@@ -72,7 +73,7 @@ CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
|
||||
|
||||
# --- File Type Extensions ---
|
||||
IMAGE_EXTENSIONS = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
|
||||
@@ -3,8 +3,6 @@ import traceback
|
||||
from urllib.parse import urlparse
|
||||
import json # Ensure json is imported
|
||||
import requests
|
||||
|
||||
# (Keep the rest of your imports)
|
||||
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
|
||||
from ..config.constants import (
|
||||
STYLE_DATE_POST_TITLE
|
||||
@@ -25,9 +23,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
raise RuntimeError("Fetch operation cancelled by user while paused.")
|
||||
time.sleep(0.5)
|
||||
logger(" Post fetching resumed.")
|
||||
|
||||
# --- MODIFICATION: Added `fields` to the URL to request only metadata ---
|
||||
# This prevents the large 'content' field from being included in the list, avoiding timeouts.
|
||||
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
|
||||
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
|
||||
|
||||
@@ -44,7 +39,6 @@ def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_ev
|
||||
logger(log_message)
|
||||
|
||||
try:
|
||||
# We can now remove the streaming logic as the response will be small and fast.
|
||||
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -80,7 +74,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
|
||||
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
|
||||
logger(f" Fetching full content for post ID {post_id}...")
|
||||
try:
|
||||
# Use streaming here as a precaution for single posts that are still very large.
|
||||
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
response_body = b""
|
||||
@@ -88,7 +81,6 @@ def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logge
|
||||
response_body += chunk
|
||||
|
||||
full_post_data = json.loads(response_body)
|
||||
# The API sometimes wraps the post in a list, handle that.
|
||||
if isinstance(full_post_data, list) and full_post_data:
|
||||
return full_post_data[0]
|
||||
return full_post_data
|
||||
@@ -134,14 +126,10 @@ def download_from_api(
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
# --- ADD THIS BLOCK ---
|
||||
# Ensure processed_post_ids is a set for fast lookups
|
||||
if processed_post_ids is None:
|
||||
processed_post_ids = set()
|
||||
else:
|
||||
processed_post_ids = set(processed_post_ids)
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
service, user_id, target_post_id = extract_post_info(api_url_input)
|
||||
|
||||
@@ -158,11 +146,9 @@ def download_from_api(
|
||||
if use_cookie and app_base_dir:
|
||||
cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
|
||||
if target_post_id:
|
||||
# --- ADD THIS CHECK FOR RESTORE ---
|
||||
if target_post_id in processed_post_ids:
|
||||
logger(f" Skipping already processed target post ID: {target_post_id}")
|
||||
return
|
||||
# --- END OF ADDITION ---
|
||||
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
|
||||
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
|
||||
try:
|
||||
@@ -248,14 +234,12 @@ def download_from_api(
|
||||
break
|
||||
if cancellation_event and cancellation_event.is_set(): return
|
||||
if all_posts_for_manga_mode:
|
||||
# --- ADD THIS BLOCK TO FILTER POSTS IN MANGA MODE ---
|
||||
if processed_post_ids:
|
||||
original_count = len(all_posts_for_manga_mode)
|
||||
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(all_posts_for_manga_mode)
|
||||
if skipped_count > 0:
|
||||
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
|
||||
def sort_key_tuple(post):
|
||||
@@ -326,15 +310,12 @@ def download_from_api(
|
||||
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
# --- ADD THIS BLOCK TO FILTER POSTS IN STANDARD MODE ---
|
||||
if processed_post_ids:
|
||||
original_count = len(posts_batch)
|
||||
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
|
||||
skipped_count = original_count - len(posts_batch)
|
||||
if skipped_count > 0:
|
||||
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
|
||||
# --- END OF ADDITION ---
|
||||
|
||||
if not posts_batch:
|
||||
if target_post_id and not processed_target_post_flag:
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
# --- Standard Library Imports ---
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# These imports reflect the new, organized project structure.
|
||||
from .api_client import download_from_api
|
||||
from .workers import PostProcessorWorker, DownloadThread
|
||||
from ..config.constants import (
|
||||
@@ -36,8 +32,6 @@ class DownloadManager:
|
||||
self.progress_queue = progress_queue
|
||||
self.thread_pool = None
|
||||
self.active_futures = []
|
||||
|
||||
# --- Session State ---
|
||||
self.cancellation_event = threading.Event()
|
||||
self.pause_event = threading.Event()
|
||||
self.is_running = False
|
||||
@@ -47,6 +41,9 @@ class DownloadManager:
|
||||
self.total_downloads = 0
|
||||
self.total_skips = 0
|
||||
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):
|
||||
"""Puts a progress message into the queue for the UI."""
|
||||
@@ -64,8 +61,13 @@ class DownloadManager:
|
||||
if self.is_running:
|
||||
self._log("❌ Cannot start a new session: A session is already in progress.")
|
||||
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.")
|
||||
|
||||
# --- Reset state for the new session ---
|
||||
self.is_running = True
|
||||
self.cancellation_event.clear()
|
||||
self.pause_event.clear()
|
||||
@@ -75,33 +77,25 @@ class DownloadManager:
|
||||
self.total_downloads = 0
|
||||
self.total_skips = 0
|
||||
self.all_kept_original_filenames = []
|
||||
|
||||
# --- Decide execution strategy (multi-threaded vs. single-threaded) ---
|
||||
is_single_post = bool(config.get('target_post_id_from_initial_url'))
|
||||
use_multithreading = config.get('use_multithreading', True)
|
||||
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
|
||||
|
||||
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
|
||||
|
||||
|
||||
if should_use_multithreading_for_posts:
|
||||
# Start a separate thread to manage fetching and queuing to the thread pool
|
||||
fetcher_thread = threading.Thread(
|
||||
target=self._fetch_and_queue_posts_for_pool,
|
||||
args=(config, restore_data),
|
||||
args=(config, restore_data, creator_profile_data), # Add argument here
|
||||
daemon=True
|
||||
)
|
||||
fetcher_thread.start()
|
||||
else:
|
||||
# For single posts or sequential manga mode, use a single worker thread
|
||||
# which is simpler and ensures order.
|
||||
self._start_single_threaded_session(config)
|
||||
|
||||
def _start_single_threaded_session(self, config):
|
||||
"""Handles downloads that are best processed by a single worker thread."""
|
||||
self._log("ℹ️ Initializing single-threaded download process...")
|
||||
|
||||
# The original DownloadThread is now a pure Python thread, not a QThread.
|
||||
# We run its `run` method in a standard Python thread.
|
||||
self.worker_thread = threading.Thread(
|
||||
target=self._run_single_worker,
|
||||
args=(config,),
|
||||
@@ -112,7 +106,6 @@ class DownloadManager:
|
||||
def _run_single_worker(self, config):
|
||||
"""Target function for the single-worker thread."""
|
||||
try:
|
||||
# Pass the queue directly to the worker for it to send updates
|
||||
worker = DownloadThread(config, self.progress_queue)
|
||||
worker.run() # This is the main blocking call for this thread
|
||||
except Exception as e:
|
||||
@@ -129,9 +122,11 @@ class DownloadManager:
|
||||
try:
|
||||
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
|
||||
|
||||
# Fetch posts
|
||||
# In a real implementation, this would call `api_client.download_from_api`
|
||||
|
||||
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:
|
||||
all_posts = restore_data['all_posts_data']
|
||||
processed_ids = set(restore_data['processed_post_ids'])
|
||||
@@ -149,12 +144,9 @@ class DownloadManager:
|
||||
if not posts_to_process:
|
||||
self._log("✅ No new posts to process.")
|
||||
return
|
||||
|
||||
# Submit tasks to the pool
|
||||
for post_data in posts_to_process:
|
||||
if self.cancellation_event.is_set():
|
||||
break
|
||||
# Each PostProcessorWorker gets the queue to send its own updates
|
||||
worker = PostProcessorWorker(post_data, config, self.progress_queue)
|
||||
future = self.thread_pool.submit(worker.process)
|
||||
future.add_done_callback(self._handle_future_result)
|
||||
@@ -164,27 +156,32 @@ class DownloadManager:
|
||||
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
|
||||
self._log(traceback.format_exc())
|
||||
finally:
|
||||
# Wait for all submitted tasks to complete before shutting down
|
||||
if self.thread_pool:
|
||||
self.thread_pool.shutdown(wait=True)
|
||||
self.is_running = False
|
||||
self._log("🏁 All processing tasks have completed.")
|
||||
# Emit final signal
|
||||
self._log("🏁 All processing tasks have completed or been cancelled.")
|
||||
self.progress_queue.put({
|
||||
'type': 'finished',
|
||||
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
|
||||
})
|
||||
|
||||
|
||||
def _get_all_posts(self, config):
|
||||
"""Helper to fetch all posts using the API client."""
|
||||
all_posts = []
|
||||
# This generator yields batches of posts
|
||||
post_generator = download_from_api(
|
||||
api_url_input=config['api_url'],
|
||||
logger=self._log,
|
||||
# ... pass other relevant config keys ...
|
||||
start_page=config.get('start_page'),
|
||||
end_page=config.get('end_page'),
|
||||
manga_mode=config.get('manga_mode_active', False),
|
||||
cancellation_event=self.cancellation_event,
|
||||
pause_event=self.pause_event
|
||||
pause_event=self.pause_event,
|
||||
use_cookie=config.get('use_cookie', False),
|
||||
cookie_text=config.get('cookie_text', ''),
|
||||
selected_cookie_file=config.get('selected_cookie_file'),
|
||||
app_base_dir=config.get('app_base_dir'),
|
||||
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
|
||||
processed_post_ids=config.get('processed_post_ids', [])
|
||||
)
|
||||
for batch in post_generator:
|
||||
all_posts.extend(batch)
|
||||
@@ -203,39 +200,70 @@ class DownloadManager:
|
||||
self.total_skips += 1
|
||||
else:
|
||||
result = future.result()
|
||||
# Unpack result tuple from the worker
|
||||
(dl_count, skip_count, kept_originals,
|
||||
retryable, permanent, history) = result
|
||||
self.total_downloads += dl_count
|
||||
self.total_skips += skip_count
|
||||
self.all_kept_original_filenames.extend(kept_originals)
|
||||
|
||||
# Queue up results for UI to handle
|
||||
if retryable:
|
||||
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
|
||||
if permanent:
|
||||
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
|
||||
if 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:
|
||||
self._log(f"❌ Worker task resulted in an exception: {e}")
|
||||
self.total_skips += 1 # Count errored posts as skipped
|
||||
|
||||
# Update overall progress
|
||||
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):
|
||||
"""Cancels the current running session."""
|
||||
if not self.is_running:
|
||||
return
|
||||
self._log("⚠️ Cancellation requested by user...")
|
||||
self.cancellation_event.set()
|
||||
|
||||
# For single thread mode, the worker checks the event
|
||||
# For multi-thread mode, shut down the pool
|
||||
if self.thread_pool:
|
||||
# Don't wait, just cancel pending futures and let the fetcher thread exit
|
||||
self.thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
self.is_running = False
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
@@ -15,15 +14,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError,
|
||||
from io import BytesIO
|
||||
from urllib .parse import urlparse
|
||||
import requests
|
||||
# --- Third-Party Library Imports ---
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
#
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
# Add a simple class to handle the header/footer for stories
|
||||
class PDF(FPDF):
|
||||
def header(self):
|
||||
pass # No header
|
||||
@@ -39,16 +35,12 @@ try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
Document = None
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5 .QtCore import Qt ,QThread ,pyqtSignal ,QMutex ,QMutexLocker ,QObject ,QTimer ,QSettings ,QStandardPaths ,QCoreApplication ,QUrl ,QSize ,QProcess
|
||||
# --- Local Application Imports ---
|
||||
from .api_client import download_from_api, fetch_post_comments
|
||||
from ..services.multipart_downloader import download_file_in_parts, MULTIPART_DOWNLOADER_AVAILABLE
|
||||
from ..services.drive_downloader import (
|
||||
download_mega_file, download_gdrive_file, download_dropbox_file
|
||||
)
|
||||
# Corrected Imports:
|
||||
from ..utils.file_utils import (
|
||||
is_image, is_video, is_zip, is_rar, is_archive, is_audio, KNOWN_NAMES,
|
||||
clean_filename, clean_folder_name
|
||||
@@ -74,7 +66,7 @@ class PostProcessorWorker:
|
||||
|
||||
def __init__(self, post_data, download_root, known_names,
|
||||
filter_character_list, emitter,
|
||||
unwanted_keywords, filter_mode, skip_zip, skip_rar,
|
||||
unwanted_keywords, filter_mode, skip_zip,
|
||||
use_subfolders, use_post_subfolders, target_post_id_from_initial_url, custom_folder_name,
|
||||
compress_images, download_thumbnails, service, user_id, pause_event,
|
||||
api_url_input, cancellation_event,
|
||||
@@ -121,7 +113,6 @@ class PostProcessorWorker:
|
||||
self.unwanted_keywords = unwanted_keywords if unwanted_keywords is not None else set()
|
||||
self.filter_mode = filter_mode
|
||||
self.skip_zip = skip_zip
|
||||
self.skip_rar = skip_rar
|
||||
self.use_subfolders = use_subfolders
|
||||
self.use_post_subfolders = use_post_subfolders
|
||||
self.target_post_id_from_initial_url = target_post_id_from_initial_url
|
||||
@@ -247,13 +238,24 @@ class PostProcessorWorker:
|
||||
|
||||
if self.manga_mode_active:
|
||||
if self.manga_filename_style == STYLE_ORIGINAL_NAME:
|
||||
filename_to_save_in_main_path = cleaned_original_api_filename
|
||||
if self.manga_date_prefix and self.manga_date_prefix.strip():
|
||||
cleaned_prefix = clean_filename(self.manga_date_prefix.strip())
|
||||
if cleaned_prefix:
|
||||
filename_to_save_in_main_path = f"{cleaned_prefix} {filename_to_save_in_main_path}"
|
||||
else:
|
||||
self.logger(f"⚠️ Manga Original Name Mode: Provided prefix '{self.manga_date_prefix}' was empty after cleaning. Using original name only.")
|
||||
# Get the post's publication or added date
|
||||
published_date_str = self.post.get('published')
|
||||
added_date_str = self.post.get('added')
|
||||
formatted_date_str = "nodate" # Fallback if no date is found
|
||||
|
||||
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:
|
||||
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
|
||||
elif self.manga_filename_style == STYLE_POST_TITLE:
|
||||
if post_title and post_title.strip():
|
||||
@@ -394,13 +396,9 @@ class PostProcessorWorker:
|
||||
if not is_audio_type:
|
||||
self.logger(f" -> Filter Skip: '{api_original_filename}' (Not Audio).")
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
if self.skip_zip and is_zip(api_original_filename):
|
||||
self.logger(f" -> Pref Skip: '{api_original_filename}' (ZIP).")
|
||||
if (self.skip_zip) and is_archive(api_original_filename):
|
||||
self.logger(f" -> Pref Skip: '{api_original_filename}' (Archive).")
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
if self.skip_rar and is_rar(api_original_filename):
|
||||
self.logger(f" -> Pref Skip: '{api_original_filename}' (RAR).")
|
||||
return 0, 1, api_original_filename, False, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
try:
|
||||
os.makedirs(target_folder_path, exist_ok=True)
|
||||
except OSError as e:
|
||||
@@ -568,15 +566,12 @@ class PostProcessorWorker:
|
||||
if self._check_pause(f"Post-download hash check for '{api_original_filename}'"):
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_SKIPPED, None
|
||||
|
||||
# --- Final Corrected Duplicate Handling Logic ---
|
||||
should_skip = False
|
||||
with self.downloaded_hash_counts_lock:
|
||||
current_count = self.downloaded_hash_counts.get(calculated_file_hash, 0)
|
||||
|
||||
# Default to not skipping
|
||||
decision_to_skip = False
|
||||
|
||||
# Apply logic based on mode
|
||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||
if current_count >= 1:
|
||||
decision_to_skip = True
|
||||
@@ -587,12 +582,10 @@ class PostProcessorWorker:
|
||||
decision_to_skip = True
|
||||
self.logger(f" -> Skip (Duplicate Limit Reached): Limit of {self.keep_duplicates_limit} for this file content has been met. Discarding.")
|
||||
|
||||
# If we are NOT skipping this file, we MUST increment the count.
|
||||
if not decision_to_skip:
|
||||
self.downloaded_hash_counts[calculated_file_hash] = current_count + 1
|
||||
|
||||
should_skip = decision_to_skip
|
||||
# --- End of Final Corrected Logic ---
|
||||
|
||||
if should_skip:
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
@@ -600,6 +593,33 @@ class PostProcessorWorker:
|
||||
os.remove(downloaded_part_file_path)
|
||||
except OSError: pass
|
||||
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
|
||||
base_name, extension = os.path.splitext(filename_to_save_in_main_path)
|
||||
@@ -617,15 +637,17 @@ class PostProcessorWorker:
|
||||
|
||||
try:
|
||||
if data_to_write_io:
|
||||
# Write the compressed data from the in-memory buffer
|
||||
with open(final_save_path, 'wb') as f_out:
|
||||
time.sleep(0.05)
|
||||
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):
|
||||
try:
|
||||
os.remove(downloaded_part_file_path)
|
||||
except OSError as e_rem:
|
||||
self.logger(f" -> Failed to remove .part after compression: {e_rem}")
|
||||
else:
|
||||
# No compression was done, just rename the original file
|
||||
if downloaded_part_file_path and os.path.exists(downloaded_part_file_path):
|
||||
time.sleep(0.1)
|
||||
os.rename(downloaded_part_file_path, final_save_path)
|
||||
@@ -684,17 +706,22 @@ class PostProcessorWorker:
|
||||
else:
|
||||
self.logger(f"->>Download Fail for '{api_original_filename}' (Post ID: {original_post_id_for_log}). No successful download after retries.")
|
||||
details_for_failure = {
|
||||
'file_info': file_info, 'target_folder_path': target_folder_path, 'headers': headers,
|
||||
'original_post_id_for_log': original_post_id_for_log, 'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post, 'num_files_in_this_post': num_files_in_this_post
|
||||
'file_info': file_info,
|
||||
'target_folder_path': target_folder_path,
|
||||
'headers': headers,
|
||||
'original_post_id_for_log': original_post_id_for_log,
|
||||
'post_title': post_title,
|
||||
'file_index_in_post': file_index_in_post,
|
||||
'num_files_in_this_post': num_files_in_this_post,
|
||||
'forced_filename_override': filename_to_save_in_main_path
|
||||
}
|
||||
if is_permanent_error:
|
||||
return 0, 1, filename_to_save_in_main_path, was_original_name_kept_flag, FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION, details_for_failure
|
||||
else:
|
||||
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):
|
||||
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
try:
|
||||
if self._check_pause(f"Post processing for ID {self.post.get('id', 'N/A')}"):
|
||||
@@ -729,7 +756,8 @@ class PostProcessorWorker:
|
||||
effective_unwanted_keywords_for_folder_naming.update(self.creator_download_folder_ignore_words)
|
||||
|
||||
post_content_html = post_data.get('content', '')
|
||||
self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---")
|
||||
if not self.extract_links_only:
|
||||
self.logger(f"\n--- Processing Post {post_id} ('{post_title[:50]}...') (Thread: {threading.current_thread().name}) ---")
|
||||
num_potential_files_in_post = len(post_attachments or []) + (1 if post_main_file_info and post_main_file_info.get('path') else 0)
|
||||
|
||||
post_is_candidate_by_title_char_match = False
|
||||
@@ -859,17 +887,6 @@ class PostProcessorWorker:
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
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:
|
||||
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)")
|
||||
@@ -880,6 +897,7 @@ class PostProcessorWorker:
|
||||
self.logger(f"⚠️ Corrupt attachment data for post {post_id} (expected list, got {type(post_attachments)}). Skipping attachments.")
|
||||
post_attachments = []
|
||||
|
||||
# CORRECTED LOGIC: Determine folder path BEFORE skip checks
|
||||
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
|
||||
if not self.extract_links_only and self.use_subfolders:
|
||||
@@ -1028,6 +1046,28 @@ class PostProcessorWorker:
|
||||
break
|
||||
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:
|
||||
self.logger(f" Mode: Text Only (Scope: {self.text_only_scope})")
|
||||
post_title_lower = post_title.lower()
|
||||
@@ -1044,7 +1084,9 @@ class PostProcessorWorker:
|
||||
return result_tuple
|
||||
|
||||
raw_text_content = ""
|
||||
comments_data = []
|
||||
final_post_data = post_data
|
||||
|
||||
if self.text_only_scope == 'content' and 'content' not in final_post_data:
|
||||
self.logger(f" Post {post_id} is missing 'content' field, fetching full data...")
|
||||
parsed_url = urlparse(self.api_url_input)
|
||||
@@ -1054,6 +1096,7 @@ class PostProcessorWorker:
|
||||
full_data = fetch_single_post_data(api_domain, self.service, self.user_id, post_id, headers, self.logger, cookies_dict=cookies)
|
||||
if full_data:
|
||||
final_post_data = full_data
|
||||
|
||||
if self.text_only_scope == 'content':
|
||||
raw_text_content = final_post_data.get('content', '')
|
||||
elif self.text_only_scope == 'comments':
|
||||
@@ -1064,46 +1107,46 @@ class PostProcessorWorker:
|
||||
if comments_data:
|
||||
comment_texts = []
|
||||
for comment in comments_data:
|
||||
user = comment.get('user', {}).get('name', 'Unknown User')
|
||||
timestamp = comment.get('updated', 'No Date')
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
comment_texts.append(f"--- Comment by {user} on {timestamp} ---\n{body}\n")
|
||||
raw_text_content = "\n".join(comment_texts)
|
||||
else:
|
||||
raw_text_content = ""
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Error fetching comments for text-only mode: {e}")
|
||||
|
||||
if not raw_text_content or not raw_text_content.strip():
|
||||
cleaned_text = ""
|
||||
if self.text_only_scope == 'content':
|
||||
if not raw_text_content:
|
||||
cleaned_text = ""
|
||||
else:
|
||||
text_with_newlines = re.sub(r'(?i)</p>|<br\s*/?>', '\n', raw_text_content)
|
||||
just_text = re.sub(r'<.*?>', '', text_with_newlines)
|
||||
cleaned_text = html.unescape(just_text).strip()
|
||||
else:
|
||||
cleaned_text = raw_text_content
|
||||
|
||||
cleaned_text = cleaned_text.replace('…', '...')
|
||||
|
||||
if not cleaned_text.strip():
|
||||
self.logger(" -> Skip Saving Text: No content/comments found or fetched.")
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
return result_tuple
|
||||
|
||||
paragraph_pattern = re.compile(r'<p.*?>(.*?)</p>', re.IGNORECASE | re.DOTALL)
|
||||
html_paragraphs = paragraph_pattern.findall(raw_text_content)
|
||||
cleaned_text = ""
|
||||
if not html_paragraphs:
|
||||
self.logger(" ⚠️ No <p> tags found. Falling back to basic HTML cleaning for the whole block.")
|
||||
text_with_br = re.sub(r'<br\s*/?>', '\n', raw_text_content, flags=re.IGNORECASE)
|
||||
cleaned_text = re.sub(r'<.*?>', '', text_with_br)
|
||||
else:
|
||||
cleaned_paragraphs_list = []
|
||||
for p_content in html_paragraphs:
|
||||
p_with_br = re.sub(r'<br\s*/?>', '\n', p_content, flags=re.IGNORECASE)
|
||||
p_cleaned = re.sub(r'<.*?>', '', p_with_br)
|
||||
p_final = html.unescape(p_cleaned).strip()
|
||||
if p_final:
|
||||
cleaned_paragraphs_list.append(p_final)
|
||||
cleaned_text = '\n\n'.join(cleaned_paragraphs_list)
|
||||
cleaned_text = cleaned_text.replace('…', '...')
|
||||
|
||||
if self.single_pdf_mode:
|
||||
if not cleaned_text:
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
return result_tuple
|
||||
content_data = {
|
||||
'title': post_title,
|
||||
'content': cleaned_text,
|
||||
'published': self.post.get('published') or self.post.get('added')
|
||||
}
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data: return (0, 0, [], [], [], None, None)
|
||||
content_data['comments'] = comments_data
|
||||
else:
|
||||
if not cleaned_text.strip(): return (0, 0, [], [], [], None, None)
|
||||
content_data['content'] = cleaned_text
|
||||
|
||||
temp_dir = os.path.join(self.app_base_dir, "appdata")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
temp_filename = f"tmp_{post_id}_{uuid.uuid4().hex[:8]}.json"
|
||||
@@ -1111,13 +1154,11 @@ class PostProcessorWorker:
|
||||
try:
|
||||
with open(temp_filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(content_data, f, indent=2)
|
||||
self.logger(f" Saved temporary text for '{post_title}' for single PDF compilation.")
|
||||
result_tuple = (0, 0, [], [], [], None, temp_filepath)
|
||||
return result_tuple
|
||||
self.logger(f" Saved temporary data for '{post_title}' for single PDF compilation.")
|
||||
return (0, 0, [], [], [], None, temp_filepath)
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Failed to write temporary file for single PDF: {e}")
|
||||
result_tuple = (0, 0, [], [], [], None, None)
|
||||
return result_tuple
|
||||
return (0, 0, [], [], [], None, None)
|
||||
else:
|
||||
file_extension = self.text_export_format
|
||||
txt_filename = clean_filename(post_title) + f".{file_extension}"
|
||||
@@ -1129,27 +1170,63 @@ class PostProcessorWorker:
|
||||
while os.path.exists(final_save_path):
|
||||
final_save_path = f"{base}_{counter}{ext}"
|
||||
counter += 1
|
||||
|
||||
if file_extension == 'pdf':
|
||||
if FPDF:
|
||||
self.logger(f" Converting to PDF...")
|
||||
self.logger(f" Creating formatted PDF for {'comments' if self.text_only_scope == 'comments' else 'content'}...")
|
||||
pdf = PDF()
|
||||
font_path = ""
|
||||
bold_font_path = ""
|
||||
if self.project_root_dir:
|
||||
font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans.ttf')
|
||||
bold_font_path = os.path.join(self.project_root_dir, 'data', 'dejavu-sans', 'DejaVuSans-Bold.ttf')
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.set_font('DejaVu', '', 12)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
default_font_family = 'DejaVu'
|
||||
except Exception as font_error:
|
||||
self.logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
pdf.set_font('Arial', '', 12)
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
pdf.multi_cell(0, 5, cleaned_text)
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(0, 10, post_title)
|
||||
pdf.ln(10)
|
||||
|
||||
if self.text_only_scope == 'comments':
|
||||
if not comments_data:
|
||||
self.logger(" -> Skip PDF Creation: No comments to process.")
|
||||
return (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
for i, comment in enumerate(comments_data):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, user)
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
if i < len(comments_data) - 1:
|
||||
pdf.ln(5)
|
||||
pdf.cell(0, 0, '', border='T')
|
||||
pdf.ln(5)
|
||||
else:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(0, 7, cleaned_text)
|
||||
|
||||
pdf.output(final_save_path)
|
||||
else:
|
||||
self.logger(f" ⚠️ Cannot create PDF: 'fpdf2' library not installed. Saving as .txt.")
|
||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||
|
||||
elif file_extension == 'docx':
|
||||
if Document:
|
||||
self.logger(f" Converting to DOCX...")
|
||||
@@ -1160,12 +1237,15 @@ class PostProcessorWorker:
|
||||
self.logger(f" ⚠️ Cannot create DOCX: 'python-docx' library not installed. Saving as .txt.")
|
||||
final_save_path = os.path.splitext(final_save_path)[0] + ".txt"
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f: f.write(cleaned_text)
|
||||
else:
|
||||
|
||||
else: # TXT file
|
||||
with open(final_save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(cleaned_text)
|
||||
|
||||
self.logger(f"✅ Saved Text: '{os.path.basename(final_save_path)}' in '{os.path.basename(determined_post_save_path_for_history)}'")
|
||||
result_tuple = (1, num_potential_files_in_post, [], [], [], history_data_for_this_post, None)
|
||||
return result_tuple
|
||||
|
||||
except Exception as e:
|
||||
self.logger(f" ❌ Critical error saving text file '{txt_filename}': {e}")
|
||||
result_tuple = (0, num_potential_files_in_post, [], [], [], None, None)
|
||||
@@ -1264,13 +1344,9 @@ class PostProcessorWorker:
|
||||
else:
|
||||
self.logger(f" ⚠️ Skipping invalid attachment {idx + 1} for post {post_id}: {str(att_info)[:100]}")
|
||||
|
||||
# --- START: Conditionally de-duplicate files from API response ---
|
||||
# Only de-duplicate by URL if we are in the default hash-skipping mode.
|
||||
# If the user wants to keep everything, we must process all entries from the API.
|
||||
if self.keep_duplicates_mode == DUPLICATE_HANDLING_HASH:
|
||||
unique_files_by_url = {}
|
||||
for file_info in all_files_from_post_api:
|
||||
# Use the file URL as a unique key to avoid processing the same file multiple times
|
||||
file_url = file_info.get('url')
|
||||
if file_url and file_url not in unique_files_by_url:
|
||||
unique_files_by_url[file_url] = file_info
|
||||
@@ -1281,7 +1357,6 @@ class PostProcessorWorker:
|
||||
|
||||
if new_count < original_count:
|
||||
self.logger(f" De-duplicated file list: Removed {original_count - new_count} redundant entries from the API response.")
|
||||
# --- END: Conditionally de-duplicate files from API response ---
|
||||
|
||||
if self.scan_content_for_images and post_content_html and not self.extract_links_only:
|
||||
self.logger(f" Scanning post content for additional image URLs (Post ID: {post_id})...")
|
||||
@@ -1361,7 +1436,17 @@ class PostProcessorWorker:
|
||||
|
||||
if not all_files_from_post_api:
|
||||
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
|
||||
|
||||
files_to_download_info_list = []
|
||||
@@ -1614,7 +1699,7 @@ class DownloadThread(QThread):
|
||||
def __init__(self, api_url_input, output_dir, known_names_copy,
|
||||
cancellation_event,
|
||||
pause_event, filter_character_list=None, dynamic_character_filter_holder=None,
|
||||
filter_mode='all', skip_zip=True, skip_rar=True,
|
||||
filter_mode='all', skip_zip=True,
|
||||
use_subfolders=True, use_post_subfolders=False, custom_folder_name=None, compress_images=False,
|
||||
download_thumbnails=False, service=None, user_id=None,
|
||||
downloaded_files=None, downloaded_file_hashes=None, downloaded_files_lock=None, downloaded_file_hashes_lock=None,
|
||||
@@ -1654,7 +1739,8 @@ class DownloadThread(QThread):
|
||||
text_export_format='txt',
|
||||
single_pdf_mode=False,
|
||||
project_root_dir=None,
|
||||
processed_post_ids=None):
|
||||
processed_post_ids=None,
|
||||
start_offset=0):
|
||||
super().__init__()
|
||||
self.api_url_input = api_url_input
|
||||
self.output_dir = output_dir
|
||||
@@ -1667,7 +1753,6 @@ class DownloadThread(QThread):
|
||||
self.dynamic_filter_holder = dynamic_character_filter_holder
|
||||
self.filter_mode = filter_mode
|
||||
self.skip_zip = skip_zip
|
||||
self.skip_rar = skip_rar
|
||||
self.use_subfolders = use_subfolders
|
||||
self.use_post_subfolders = use_post_subfolders
|
||||
self.custom_folder_name = custom_folder_name
|
||||
@@ -1717,7 +1802,8 @@ class DownloadThread(QThread):
|
||||
self.text_export_format = text_export_format
|
||||
self.single_pdf_mode = single_pdf_mode
|
||||
self.project_root_dir = project_root_dir
|
||||
self.processed_post_ids = processed_post_ids if processed_post_ids is not None else []
|
||||
self.processed_post_ids_set = set(processed_post_ids) if processed_post_ids is not None else set()
|
||||
self.start_offset = start_offset
|
||||
|
||||
if self.compress_images and Image is None:
|
||||
self.logger("⚠️ Image compression disabled: Pillow library not found (DownloadThread).")
|
||||
@@ -1730,7 +1816,9 @@ class DownloadThread(QThread):
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
The main execution method for the single-threaded download process.
|
||||
The main execution method for the download process.
|
||||
This version correctly uses the central `download_from_api` function
|
||||
and explicitly maps all arguments to the PostProcessorWorker to prevent TypeErrors.
|
||||
"""
|
||||
grand_total_downloaded_files = 0
|
||||
grand_total_skipped_files = 0
|
||||
@@ -1739,7 +1827,6 @@ class DownloadThread(QThread):
|
||||
|
||||
worker_signals_obj = PostProcessorSignals()
|
||||
try:
|
||||
# Connect signals
|
||||
worker_signals_obj.progress_signal.connect(self.progress_signal)
|
||||
worker_signals_obj.file_download_status_signal.connect(self.file_download_status_signal)
|
||||
worker_signals_obj.file_progress_signal.connect(self.file_progress_signal)
|
||||
@@ -1749,6 +1836,7 @@ class DownloadThread(QThread):
|
||||
worker_signals_obj.worker_finished_signal.connect(lambda result: None)
|
||||
|
||||
self.logger(" Starting post fetch (single-threaded download process)...")
|
||||
|
||||
post_generator = download_from_api(
|
||||
self.api_url_input,
|
||||
logger=self.logger,
|
||||
@@ -1762,118 +1850,111 @@ class DownloadThread(QThread):
|
||||
selected_cookie_file=self.selected_cookie_file,
|
||||
app_base_dir=self.app_base_dir,
|
||||
manga_filename_style_for_sort_check=self.manga_filename_style if self.manga_mode_active else None,
|
||||
# --- FIX: ADDED A COMMA to the line above ---
|
||||
processed_post_ids=self.processed_post_ids
|
||||
processed_post_ids=self.processed_post_ids_set
|
||||
)
|
||||
|
||||
for posts_batch_data in post_generator:
|
||||
if self.isInterruptionRequested():
|
||||
was_process_cancelled = True
|
||||
break
|
||||
|
||||
for individual_post_data in posts_batch_data:
|
||||
if self.isInterruptionRequested():
|
||||
was_process_cancelled = True
|
||||
break
|
||||
|
||||
post_processing_worker = PostProcessorWorker(
|
||||
post_data=individual_post_data,
|
||||
download_root=self.output_dir,
|
||||
known_names=self.known_names,
|
||||
filter_character_list=self.filter_character_list_objects_initial,
|
||||
dynamic_character_filter_holder=self.dynamic_filter_holder,
|
||||
unwanted_keywords=self.unwanted_keywords,
|
||||
filter_mode=self.filter_mode,
|
||||
skip_zip=self.skip_zip, skip_rar=self.skip_rar,
|
||||
use_subfolders=self.use_subfolders, use_post_subfolders=self.use_post_subfolders,
|
||||
target_post_id_from_initial_url=self.initial_target_post_id,
|
||||
custom_folder_name=self.custom_folder_name,
|
||||
compress_images=self.compress_images, download_thumbnails=self.download_thumbnails,
|
||||
service=self.service, user_id=self.user_id,
|
||||
api_url_input=self.api_url_input,
|
||||
pause_event=self.pause_event,
|
||||
cancellation_event=self.cancellation_event,
|
||||
emitter=worker_signals_obj,
|
||||
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,
|
||||
skip_words_list=self.skip_words_list,
|
||||
skip_words_scope=self.skip_words_scope,
|
||||
show_external_links=self.show_external_links,
|
||||
extract_links_only=self.extract_links_only,
|
||||
num_file_threads=self.num_file_threads_for_worker,
|
||||
skip_current_file_flag=self.skip_current_file_flag,
|
||||
manga_mode_active=self.manga_mode_active,
|
||||
manga_filename_style=self.manga_filename_style,
|
||||
manga_date_prefix=self.manga_date_prefix,
|
||||
char_filter_scope=self.char_filter_scope,
|
||||
remove_from_filename_words_list=self.remove_from_filename_words_list,
|
||||
allow_multipart_download=self.allow_multipart_download,
|
||||
selected_cookie_file=self.selected_cookie_file,
|
||||
app_base_dir=self.app_base_dir,
|
||||
cookie_text=self.cookie_text,
|
||||
override_output_dir=self.override_output_dir,
|
||||
manga_global_file_counter_ref=self.manga_global_file_counter_ref,
|
||||
use_cookie=self.use_cookie,
|
||||
manga_date_file_counter_ref=self.manga_date_file_counter_ref,
|
||||
use_date_prefix_for_subfolder=self.use_date_prefix_for_subfolder,
|
||||
keep_in_post_duplicates=self.keep_in_post_duplicates,
|
||||
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,
|
||||
creator_download_folder_ignore_words=self.creator_download_folder_ignore_words,
|
||||
session_file_path=self.session_file_path,
|
||||
session_lock=self.session_lock,
|
||||
text_only_scope=self.text_only_scope,
|
||||
text_export_format=self.text_export_format,
|
||||
single_pdf_mode=self.single_pdf_mode,
|
||||
project_root_dir=self.project_root_dir
|
||||
)
|
||||
try:
|
||||
(dl_count, skip_count, kept_originals_this_post,
|
||||
retryable_failures, permanent_failures,
|
||||
history_data, temp_filepath) = post_processing_worker.process()
|
||||
worker_args = {
|
||||
'post_data': individual_post_data,
|
||||
'emitter': worker_signals_obj,
|
||||
'download_root': self.output_dir,
|
||||
'known_names': self.known_names,
|
||||
'filter_character_list': self.filter_character_list_objects_initial,
|
||||
'dynamic_character_filter_holder': self.dynamic_filter_holder,
|
||||
'target_post_id_from_initial_url': self.initial_target_post_id,
|
||||
'num_file_threads': self.num_file_threads_for_worker,
|
||||
'processed_post_ids': list(self.processed_post_ids_set),
|
||||
'unwanted_keywords': self.unwanted_keywords,
|
||||
'filter_mode': self.filter_mode,
|
||||
'skip_zip': self.skip_zip,
|
||||
'use_subfolders': self.use_subfolders,
|
||||
'use_post_subfolders': self.use_post_subfolders,
|
||||
'custom_folder_name': self.custom_folder_name,
|
||||
'compress_images': self.compress_images,
|
||||
'download_thumbnails': self.download_thumbnails,
|
||||
'service': self.service,
|
||||
'user_id': self.user_id,
|
||||
'api_url_input': self.api_url_input,
|
||||
'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,
|
||||
'skip_words_list': self.skip_words_list,
|
||||
'skip_words_scope': self.skip_words_scope,
|
||||
'show_external_links': self.show_external_links,
|
||||
'extract_links_only': self.extract_links_only,
|
||||
'skip_current_file_flag': self.skip_current_file_flag,
|
||||
'manga_mode_active': self.manga_mode_active,
|
||||
'manga_filename_style': self.manga_filename_style,
|
||||
'char_filter_scope': self.char_filter_scope,
|
||||
'remove_from_filename_words_list': self.remove_from_filename_words_list,
|
||||
'allow_multipart_download': self.allow_multipart_download,
|
||||
'cookie_text': self.cookie_text,
|
||||
'use_cookie': self.use_cookie,
|
||||
'override_output_dir': self.override_output_dir,
|
||||
'selected_cookie_file': self.selected_cookie_file,
|
||||
'app_base_dir': self.app_base_dir,
|
||||
'manga_date_prefix': self.manga_date_prefix,
|
||||
'manga_date_file_counter_ref': self.manga_date_file_counter_ref,
|
||||
'scan_content_for_images': self.scan_content_for_images,
|
||||
'creator_download_folder_ignore_words': self.creator_download_folder_ignore_words,
|
||||
'manga_global_file_counter_ref': self.manga_global_file_counter_ref,
|
||||
'use_date_prefix_for_subfolder': self.use_date_prefix_for_subfolder,
|
||||
'keep_in_post_duplicates': self.keep_in_post_duplicates,
|
||||
'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.text_only_scope,
|
||||
'text_export_format': self.text_export_format,
|
||||
'single_pdf_mode': self.single_pdf_mode,
|
||||
'project_root_dir': self.project_root_dir,
|
||||
}
|
||||
|
||||
grand_total_downloaded_files += dl_count
|
||||
grand_total_skipped_files += skip_count
|
||||
post_processing_worker = PostProcessorWorker(**worker_args)
|
||||
|
||||
if kept_originals_this_post:
|
||||
grand_list_of_kept_original_filenames.extend(kept_originals_this_post)
|
||||
if retryable_failures:
|
||||
self.retryable_file_failed_signal.emit(retryable_failures)
|
||||
if history_data:
|
||||
if len(self.history_candidates_buffer) < 8:
|
||||
self.post_processed_for_history_signal.emit(history_data)
|
||||
if permanent_failures:
|
||||
self.permanent_file_failed_signal.emit(permanent_failures)
|
||||
(dl_count, skip_count, kept_originals_this_post,
|
||||
retryable_failures, permanent_failures,
|
||||
history_data, temp_filepath) = post_processing_worker.process()
|
||||
|
||||
if self.single_pdf_mode and temp_filepath:
|
||||
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}")
|
||||
grand_total_downloaded_files += dl_count
|
||||
grand_total_skipped_files += skip_count
|
||||
if kept_originals_this_post:
|
||||
grand_list_of_kept_original_filenames.extend(kept_originals_this_post)
|
||||
if retryable_failures:
|
||||
self.retryable_file_failed_signal.emit(retryable_failures)
|
||||
if history_data:
|
||||
self.post_processed_for_history_signal.emit(history_data)
|
||||
if permanent_failures:
|
||||
self.permanent_file_failed_signal.emit(permanent_failures)
|
||||
if self.single_pdf_mode and temp_filepath:
|
||||
self.progress_signal.emit(f"TEMP_FILE_PATH:{temp_filepath}")
|
||||
|
||||
except Exception as proc_err:
|
||||
post_id_for_err = individual_post_data.get('id', 'N/A')
|
||||
self.logger(f"❌ Error processing post {post_id_for_err} in DownloadThread: {proc_err}")
|
||||
traceback.print_exc()
|
||||
num_potential_files_est = len(individual_post_data.get('attachments', [])) + (
|
||||
1 if individual_post_data.get('file') else 0)
|
||||
grand_total_skipped_files += num_potential_files_est
|
||||
|
||||
if self.skip_current_file_flag and self.skip_current_file_flag.is_set():
|
||||
self.skip_current_file_flag.clear()
|
||||
self.logger(" Skip current file flag was processed and cleared by DownloadThread.")
|
||||
self.msleep(10)
|
||||
if was_process_cancelled:
|
||||
break
|
||||
|
||||
if not was_process_cancelled and not self.isInterruptionRequested():
|
||||
self.logger("✅ All posts processed or end of content reached by DownloadThread.")
|
||||
|
||||
|
||||
except Exception as main_thread_err:
|
||||
self.logger(f"\n❌ Critical error within DownloadThread run loop: {main_thread_err}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
try:
|
||||
# Disconnect signals
|
||||
if worker_signals_obj:
|
||||
worker_signals_obj.progress_signal.disconnect(self.progress_signal)
|
||||
worker_signals_obj.file_download_status_signal.disconnect(self.file_download_status_signal)
|
||||
@@ -1883,15 +1964,9 @@ class DownloadThread(QThread):
|
||||
worker_signals_obj.file_successfully_downloaded_signal.disconnect(self.file_successfully_downloaded_signal)
|
||||
except (TypeError, RuntimeError) as e:
|
||||
self.logger(f"ℹ️ Note during DownloadThread signal disconnection: {e}")
|
||||
|
||||
# Emit the final signal with all collected results
|
||||
|
||||
self.finished_signal.emit(grand_total_downloaded_files, grand_total_skipped_files, self.isInterruptionRequested(), grand_list_of_kept_original_filenames)
|
||||
|
||||
def receive_add_character_result (self ,result ):
|
||||
with QMutexLocker (self .prompt_mutex ):
|
||||
self ._add_character_response =result
|
||||
self .logger (f" (DownloadThread) Received character prompt response: {'Yes (added/confirmed)'if result else 'No (declined/failed)'}")
|
||||
|
||||
class InterruptedError(Exception):
|
||||
"""Custom exception for handling cancellations gracefully."""
|
||||
pass
|
||||
File diff suppressed because one or more lines are too long
@@ -15,9 +15,9 @@ except ImportError:
|
||||
|
||||
try:
|
||||
import gdown
|
||||
GDOWN_AVAILABLE = True
|
||||
GDRIVE_AVAILABLE = True
|
||||
except ImportError:
|
||||
GDOWN_AVAILABLE = False
|
||||
GDRIVE_AVAILABLE = False
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
@@ -46,75 +46,76 @@ def _get_filename_from_headers(headers):
|
||||
|
||||
# --- 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.
|
||||
|
||||
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.
|
||||
Downloads a file from a Mega.nz URL.
|
||||
Handles both public links and links that include a decryption key.
|
||||
"""
|
||||
if not MEGA_AVAILABLE:
|
||||
logger_func("❌ Error: mega.py library is not installed. Cannot download from Mega.")
|
||||
logger_func(" Please install it: pip install mega.py")
|
||||
raise ImportError("mega.py library not found.")
|
||||
logger_func("❌ Mega download failed: 'mega.py' library is not installed.")
|
||||
return
|
||||
|
||||
logger_func(f" [Mega] Initializing Mega client...")
|
||||
try:
|
||||
mega_client = Mega()
|
||||
m = mega_client.login()
|
||||
logger_func(f" [Mega] Attempting to download from: {mega_link}")
|
||||
|
||||
if not os.path.exists(download_path):
|
||||
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)
|
||||
mega = Mega()
|
||||
# Anonymous login is sufficient for public links
|
||||
m = mega.login()
|
||||
|
||||
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}")
|
||||
# --- MODIFIED PART: Added error handling for invalid links ---
|
||||
try:
|
||||
file_details = m.find(mega_url)
|
||||
if file_details is None:
|
||||
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 ---
|
||||
|
||||
filename = file_details[1]['a']['n']
|
||||
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
|
||||
|
||||
# Sanitize filename before saving
|
||||
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
|
||||
final_path = os.path.join(download_path, safe_filename)
|
||||
|
||||
# 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:
|
||||
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
|
||||
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
|
||||
|
||||
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}")
|
||||
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:
|
||||
if not os.path.exists(download_path):
|
||||
os.makedirs(download_path, exist_ok=True)
|
||||
logger_func(f" [GDrive] Created download directory: {download_path}")
|
||||
|
||||
# gdown handles finding the file ID and downloading. 'fuzzy=True' helps with various URL formats.
|
||||
output_file_path = gdown.download(gdrive_link, output=download_path, quiet=False, fuzzy=True)
|
||||
|
||||
if output_file_path and os.path.exists(output_file_path):
|
||||
logger_func(f" [GDrive] ✅ Google Drive file downloaded successfully: {output_file_path}")
|
||||
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:
|
||||
raise Exception(f"gdown download failed or file not found. Returned: {output_file_path}")
|
||||
|
||||
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" [GDrive] ❌ An error occurred during Google Drive download: {e}")
|
||||
traceback.print_exc(limit=2)
|
||||
raise
|
||||
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
|
||||
|
||||
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
|
||||
"""
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
# --- Asset Management ---
|
||||
|
||||
# This global variable will cache the icon so we don't have to load it from disk every time.
|
||||
_app_icon_cache = None
|
||||
|
||||
def get_app_icon_object():
|
||||
@@ -22,17 +16,11 @@ def get_app_icon_object():
|
||||
if _app_icon_cache and not _app_icon_cache.isNull():
|
||||
return _app_icon_cache
|
||||
|
||||
# Declare a single variable to hold the base directory path.
|
||||
app_base_dir = ""
|
||||
|
||||
# Determine the project's base directory, whether running from source or as a bundled app
|
||||
if getattr(sys, 'frozen', False):
|
||||
# The application is frozen (e.g., with PyInstaller).
|
||||
# The base directory is the one containing the executable.
|
||||
app_base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
# The application is running from a .py file.
|
||||
# This path navigates up from src/ui/assets.py to the project root.
|
||||
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
|
||||
@@ -40,7 +28,6 @@ def get_app_icon_object():
|
||||
if os.path.exists(icon_path):
|
||||
_app_icon_cache = QIcon(icon_path)
|
||||
else:
|
||||
# If the icon isn't found, especially in a frozen app, check the _MEIPASS directory as a fallback.
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
|
||||
if os.path.exists(fallback_icon_path):
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QPushButton, QVBoxLayout
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# This assumes the new project structure is in place.
|
||||
from ...i18n.translator import get_translation
|
||||
# get_app_icon_object is defined in the main window module in this refactoring plan.
|
||||
from ..main_window import get_app_icon_object
|
||||
|
||||
# --- Constants for Dialog Choices ---
|
||||
# These were moved from main.py to be self-contained within this module's context.
|
||||
CONFIRM_ADD_ALL_ACCEPTED = 1
|
||||
CONFIRM_ADD_ALL_SKIP_ADDING = 2
|
||||
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
|
||||
@@ -38,23 +30,16 @@ class ConfirmAddAllDialog(QDialog):
|
||||
self.parent_app = parent_app
|
||||
self.setModal(True)
|
||||
self.new_filter_objects_list = new_filter_objects_list
|
||||
# Default choice if the dialog is closed without a button press
|
||||
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
|
||||
|
||||
# --- Basic Window Setup ---
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 768.0
|
||||
base_min_w, base_min_h = 480, 350
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
self._apply_theme()
|
||||
@@ -70,8 +55,6 @@ class ConfirmAddAllDialog(QDialog):
|
||||
self.names_list_widget = QListWidget()
|
||||
self._populate_list()
|
||||
main_layout.addWidget(self.names_list_widget)
|
||||
|
||||
# --- Selection Buttons ---
|
||||
selection_buttons_layout = QHBoxLayout()
|
||||
self.select_all_button = QPushButton()
|
||||
self.select_all_button.clicked.connect(self._select_all_items)
|
||||
@@ -82,8 +65,6 @@ class ConfirmAddAllDialog(QDialog):
|
||||
selection_buttons_layout.addWidget(self.deselect_all_button)
|
||||
selection_buttons_layout.addStretch()
|
||||
main_layout.addLayout(selection_buttons_layout)
|
||||
|
||||
# --- Action Buttons ---
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.add_selected_button = QPushButton()
|
||||
self.add_selected_button.clicked.connect(self._accept_add_selected)
|
||||
@@ -171,7 +152,6 @@ class ConfirmAddAllDialog(QDialog):
|
||||
sensible default if no items are selected but the "Add" button is clicked.
|
||||
"""
|
||||
super().exec_()
|
||||
# If the user clicked "Add Selected" but didn't select any items, treat it as skipping.
|
||||
if isinstance(self.user_choice, list) and not self.user_choice:
|
||||
return CONFIRM_ADD_ALL_SKIP_ADDING
|
||||
return self.user_choice
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
# --- Standard Library Imports ---
|
||||
from collections import defaultdict
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import pyqtSignal, Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
# This assumes the new project structure is in place.
|
||||
from ...i18n.translator import get_translation
|
||||
# get_app_icon_object is defined in the main window module in this refactoring plan.
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -20,8 +13,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
A dialog to select and initiate the download for extracted, supported links
|
||||
from external cloud services like Mega, Google Drive, and Dropbox.
|
||||
"""
|
||||
|
||||
# Signal emitted with a list of selected link information dictionaries
|
||||
download_requested = pyqtSignal(list)
|
||||
|
||||
def __init__(self, links_data, parent_app, parent=None):
|
||||
@@ -36,29 +27,13 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.links_data = links_data
|
||||
self.parent_app = parent_app
|
||||
|
||||
# --- Basic Window Setup ---
|
||||
app_icon = get_app_icon_object()
|
||||
if not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically based on the parent window's size
|
||||
if parent:
|
||||
parent_width = parent.width()
|
||||
parent_height = parent.height()
|
||||
# Use a scaling factor for different screen resolutions
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 768.0
|
||||
|
||||
base_min_w, base_min_h = 500, 400
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
self.resize(max(int(parent_width * 0.6 * scale_factor), scaled_min_w),
|
||||
max(int(parent_height * 0.7 * scale_factor), scaled_min_h))
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
base_width, base_height = 600, 450
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
self._init_ui()
|
||||
self._retranslate_ui()
|
||||
self._apply_theme()
|
||||
@@ -76,8 +51,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
|
||||
self._populate_list()
|
||||
layout.addWidget(self.links_list_widget)
|
||||
|
||||
# --- Control Buttons ---
|
||||
button_layout = QHBoxLayout()
|
||||
self.select_all_button = QPushButton()
|
||||
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
|
||||
@@ -108,7 +81,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
|
||||
|
||||
for post_title_key in sorted_post_titles:
|
||||
# Add a non-selectable header for each post
|
||||
header_item = QListWidgetItem(f"{post_title_key}")
|
||||
header_item.setFlags(Qt.NoItemFlags)
|
||||
font = header_item.font()
|
||||
@@ -116,8 +88,6 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
font.setPointSize(font.pointSize() + 1)
|
||||
header_item.setFont(font)
|
||||
self.links_list_widget.addItem(header_item)
|
||||
|
||||
# Add checkable items for each link within that post
|
||||
for link_info_data in grouped_links[post_title_key]:
|
||||
platform_display = link_info_data.get('platform', 'unknown').upper()
|
||||
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
|
||||
@@ -147,19 +117,13 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
|
||||
|
||||
if is_dark_theme:
|
||||
# Get the scale factor from the parent app
|
||||
scale = getattr(self.parent_app, 'scale_factor', 1)
|
||||
# Call the imported function with the correct scale
|
||||
self.setStyleSheet(get_dark_theme(scale))
|
||||
else:
|
||||
# Explicitly set a blank stylesheet for light mode
|
||||
self.setStyleSheet("")
|
||||
|
||||
# Set header text color based on theme
|
||||
header_color = Qt.cyan if is_dark_theme else Qt.blue
|
||||
for i in range(self.links_list_widget.count()):
|
||||
item = self.links_list_widget.item(i)
|
||||
# Headers are not checkable (they have no checkable flag)
|
||||
if not item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setForeground(header_color)
|
||||
|
||||
@@ -186,4 +150,4 @@ class DownloadExtractedLinksDialog(QDialog):
|
||||
self,
|
||||
self._tr("no_selection_title", "No Selection"),
|
||||
self._tr("no_selection_message_links", "Please select at least one link to download.")
|
||||
)
|
||||
)
|
||||
@@ -1,16 +1,12 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
|
||||
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
|
||||
QFileDialog, QMessageBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
@@ -25,17 +21,14 @@ class DownloadHistoryDialog (QDialog ):
|
||||
self .first_processed_entries =first_processed_entries
|
||||
self .setModal (True )
|
||||
self._apply_theme()
|
||||
# Patch missing creator_display_name and creator_name using parent_app.creator_name_cache if available
|
||||
creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
|
||||
if creator_name_cache:
|
||||
# Patch left pane (files)
|
||||
for entry in self.last_3_downloaded_entries:
|
||||
if not entry.get('creator_display_name'):
|
||||
service = entry.get('service', '').lower()
|
||||
user_id = str(entry.get('user_id', ''))
|
||||
key = (service, user_id)
|
||||
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
|
||||
# Patch right pane (posts)
|
||||
for entry in self.first_processed_entries:
|
||||
if not entry.get('creator_name'):
|
||||
service = entry.get('service', '').lower()
|
||||
|
||||
@@ -13,7 +13,7 @@ from PyQt5.QtCore import pyqtSignal, QCoreApplication, QSize, QThread, QTimer, Q
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView,
|
||||
QSplitter, QProgressBar, QWidget
|
||||
QSplitter, QProgressBar, QWidget, QFileDialog
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@@ -151,6 +151,8 @@ class EmptyPopupDialog (QDialog ):
|
||||
app_icon =get_app_icon_object ()
|
||||
if app_icon and not app_icon .isNull ():
|
||||
self .setWindowIcon (app_icon )
|
||||
self.update_profile_data = None
|
||||
self.update_creator_name = None
|
||||
self .selected_creators_for_queue =[]
|
||||
self .globally_selected_creators ={}
|
||||
self .fetched_posts_data ={}
|
||||
@@ -205,6 +207,9 @@ class EmptyPopupDialog (QDialog ):
|
||||
self .scope_button .clicked .connect (self ._toggle_scope_mode )
|
||||
left_bottom_buttons_layout .addWidget (self .scope_button )
|
||||
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 ()
|
||||
@@ -315,6 +320,31 @@ class EmptyPopupDialog (QDialog ):
|
||||
except AttributeError :
|
||||
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 ):
|
||||
selected_creators =list (self .globally_selected_creators .values ())
|
||||
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 .fetch_posts_button .setText (self ._tr ("fetch_posts_button_text","Fetch Posts"))
|
||||
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..."))
|
||||
|
||||
@@ -938,6 +969,9 @@ class EmptyPopupDialog (QDialog ):
|
||||
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.selected_creators_for_queue.clear()
|
||||
|
||||
self .accept ()
|
||||
else :
|
||||
QMessageBox .information (self ,self ._tr ("no_selection_title","No Selection"),
|
||||
@@ -1003,4 +1037,4 @@ class EmptyPopupDialog (QDialog ):
|
||||
else :
|
||||
if unique_key in self .globally_selected_creators :
|
||||
del self .globally_selected_creators [unique_key ]
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
self .fetch_posts_button .setEnabled (bool (self .globally_selected_creators ))
|
||||
@@ -42,13 +42,15 @@ class ErrorFilesDialog(QDialog):
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
|
||||
scale_factor = screen_height / 1080.0
|
||||
base_min_w, base_min_h = 500, 300
|
||||
scaled_min_w = int(base_min_w * scale_factor)
|
||||
scaled_min_h = int(base_min_h * scale_factor)
|
||||
self.setMinimumSize(scaled_min_w, scaled_min_h)
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
# Define base dimensions and apply the correct scale factor.
|
||||
base_width, base_height = 550, 400
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -232,4 +234,4 @@ class ErrorFilesDialog(QDialog):
|
||||
self,
|
||||
self._tr("error_files_export_error_title", "Export Error"),
|
||||
self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
|
||||
)
|
||||
)
|
||||
@@ -1,4 +1,3 @@
|
||||
# --- Standard Library Imports ---
|
||||
import html
|
||||
import os
|
||||
import sys
|
||||
@@ -8,8 +7,6 @@ import traceback
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
# --- Third-Party Library Imports ---
|
||||
import requests
|
||||
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
|
||||
from PyQt5.QtWidgets import (
|
||||
@@ -17,12 +14,9 @@ from PyQt5.QtWidgets import (
|
||||
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
|
||||
QWidget, QCheckBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..assets import get_app_icon_object
|
||||
from ...utils.network_utils import prepare_cookies_for_request
|
||||
# Corrected Import: Import CookieHelpDialog directly from its own module
|
||||
from .CookieHelpDialog import CookieHelpDialog
|
||||
from ...core.api_client import download_from_api
|
||||
from ...utils.resolution import get_dark_theme
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
from PyQt5.QtCore import Qt, QStandardPaths
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout
|
||||
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
@@ -15,7 +15,7 @@ from ...utils.resolution import get_dark_theme
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...config.constants import (
|
||||
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
|
||||
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_h = int(base_min_h * scale_factor)
|
||||
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.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.addStretch(1)
|
||||
@@ -102,6 +107,20 @@ class FutureSettingsDialog(QDialog):
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
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=""):
|
||||
if callable(get_translation) and self.parent_app:
|
||||
return get_translation(self.parent_app.current_selected_language, key, default_text)
|
||||
@@ -122,6 +141,7 @@ class FutureSettingsDialog(QDialog):
|
||||
# Download & Window Group Labels
|
||||
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.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
|
||||
|
||||
# Buttons and Controls
|
||||
self._update_theme_toggle_button_text()
|
||||
@@ -132,6 +152,7 @@ class FutureSettingsDialog(QDialog):
|
||||
# Populate dropdowns
|
||||
self._populate_display_combo_boxes()
|
||||
self._populate_language_combo_box()
|
||||
self._load_checkbox_states()
|
||||
|
||||
def _apply_theme(self):
|
||||
if self.parent_app and self.parent_app.current_theme == "dark":
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import QUrl, QSize, Qt
|
||||
from PyQt5.QtGui import QIcon, QDesktopServices
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QScrollArea, QFrame, QWidget
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
@@ -20,17 +15,20 @@ class TourStepWidget(QWidget):
|
||||
A custom widget representing a single step or page in the feature guide.
|
||||
It neatly formats a title and its corresponding content.
|
||||
"""
|
||||
def __init__(self, title_text, content_text, parent=None):
|
||||
def __init__(self, title_text, content_text, parent=None, scale=1.0):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(10)
|
||||
|
||||
title_font_size = int(14 * scale)
|
||||
content_font_size = int(11 * scale)
|
||||
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
@@ -42,8 +40,8 @@ class TourStepWidget(QWidget):
|
||||
content_label.setWordWrap(True)
|
||||
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
content_label.setTextFormat(Qt.RichText)
|
||||
content_label.setOpenExternalLinks(True) # Allow opening links in the content
|
||||
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
|
||||
content_label.setOpenExternalLinks(True)
|
||||
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
|
||||
scroll_area.setWidget(content_label)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
@@ -56,27 +54,38 @@ class HelpGuideDialog (QDialog ):
|
||||
self .steps_data =steps_data
|
||||
self .parent_app =parent_app
|
||||
|
||||
app_icon =get_app_icon_object ()
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
|
||||
app_icon = get_app_icon_object()
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
self .setModal (True )
|
||||
self .setFixedSize (650 ,600 )
|
||||
self.setModal(True)
|
||||
self.resize(int(650 * scale), int(600 * scale))
|
||||
|
||||
dialog_font_size = int(11 * scale)
|
||||
|
||||
current_theme_style = ""
|
||||
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
|
||||
current_theme_style = get_dark_theme(scale)
|
||||
else:
|
||||
current_theme_style = f"""
|
||||
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
|
||||
QLabel {{ color: #1E1E1E; }}
|
||||
QPushButton {{
|
||||
background-color: #E1E1E1;
|
||||
color: #1E1E1E;
|
||||
border: 1px solid #ADADAD;
|
||||
padding: {int(8*scale)}px {int(15*scale)}px;
|
||||
border-radius: 4px;
|
||||
min-height: {int(25*scale)}px;
|
||||
font-size: {dialog_font_size}pt;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #CACACA; }}
|
||||
QPushButton:pressed {{ background-color: #B0B0B0; }}
|
||||
"""
|
||||
|
||||
current_theme_style =""
|
||||
if hasattr (self .parent_app ,'current_theme')and self .parent_app .current_theme =="dark":
|
||||
if hasattr (self .parent_app ,'get_dark_theme'):
|
||||
current_theme_style =self .parent_app .get_dark_theme ()
|
||||
|
||||
|
||||
self .setStyleSheet (current_theme_style if current_theme_style else """
|
||||
QDialog { background-color: #2E2E2E; border: 1px solid #5A5A5A; }
|
||||
QLabel { color: #E0E0E0; }
|
||||
QPushButton { background-color: #555; color: #F0F0F0; border: 1px solid #6A6A6A; padding: 8px 15px; border-radius: 4px; min-height: 25px; font-size: 11pt; }
|
||||
QPushButton:hover { background-color: #656565; }
|
||||
QPushButton:pressed { background-color: #4A4A4A; }
|
||||
""")
|
||||
self.setStyleSheet(current_theme_style)
|
||||
self ._init_ui ()
|
||||
if self .parent_app :
|
||||
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
|
||||
@@ -97,10 +106,11 @@ class HelpGuideDialog (QDialog ):
|
||||
main_layout .addWidget (self .stacked_widget ,1 )
|
||||
|
||||
self .tour_steps_widgets =[]
|
||||
for title ,content in self .steps_data :
|
||||
step_widget =TourStepWidget (title ,content )
|
||||
self .tour_steps_widgets .append (step_widget )
|
||||
self .stacked_widget .addWidget (step_widget )
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
for title, content in self.steps_data:
|
||||
step_widget = TourStepWidget(title, content, scale=scale)
|
||||
self.tour_steps_widgets.append(step_widget)
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
|
||||
|
||||
@@ -115,7 +125,6 @@ class HelpGuideDialog (QDialog ):
|
||||
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
|
||||
assets_base_dir =sys ._MEIPASS
|
||||
else :
|
||||
# Go up three levels from this file's directory (src/ui/dialogs) to the project root
|
||||
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
|
||||
@@ -126,7 +135,9 @@ class HelpGuideDialog (QDialog ):
|
||||
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
|
||||
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
|
||||
|
||||
icon_size =QSize (24 ,24 )
|
||||
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
|
||||
icon_dim = int(24 * scale)
|
||||
icon_size = QSize(icon_dim, icon_dim)
|
||||
self .github_button .setIconSize (icon_size )
|
||||
self .instagram_button .setIconSize (icon_size )
|
||||
self .Discord_button .setIconSize (icon_size )
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# KeepDuplicatesDialog.py
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
|
||||
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
|
||||
)
|
||||
from PyQt5.QtGui import QIntValidator
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
|
||||
|
||||
@@ -25,8 +20,6 @@ class KeepDuplicatesDialog(QDialog):
|
||||
|
||||
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
|
||||
self.parent_app._apply_theme_to_widget(self)
|
||||
|
||||
# Set the initial state based on current settings
|
||||
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
|
||||
self.radio_keep_everything.setChecked(True)
|
||||
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
|
||||
@@ -44,13 +37,9 @@ class KeepDuplicatesDialog(QDialog):
|
||||
options_group = QGroupBox()
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
self.button_group = QButtonGroup(self)
|
||||
|
||||
# --- Skip by Hash Option ---
|
||||
self.radio_skip_by_hash = QRadioButton()
|
||||
self.button_group.addButton(self.radio_skip_by_hash)
|
||||
options_layout.addWidget(self.radio_skip_by_hash)
|
||||
|
||||
# --- Keep Everything Option with Limit Input ---
|
||||
keep_everything_layout = QHBoxLayout()
|
||||
self.radio_keep_everything = QRadioButton()
|
||||
self.button_group.addButton(self.radio_keep_everything)
|
||||
@@ -66,8 +55,6 @@ class KeepDuplicatesDialog(QDialog):
|
||||
options_layout.addLayout(keep_everything_layout)
|
||||
|
||||
main_layout.addWidget(options_group)
|
||||
|
||||
# --- OK and Cancel buttons ---
|
||||
button_layout = QHBoxLayout()
|
||||
self.ok_button = QPushButton()
|
||||
self.cancel_button = QPushButton()
|
||||
@@ -75,8 +62,6 @@ class KeepDuplicatesDialog(QDialog):
|
||||
button_layout.addWidget(self.ok_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
# --- Connections ---
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
|
||||
|
||||
@@ -14,7 +14,6 @@ class KnownNamesFilterDialog(QDialog):
|
||||
"""
|
||||
A dialog to select names from the Known.txt list to add to the main
|
||||
character filter input field. This provides a convenient way for users
|
||||
|
||||
to reuse their saved names and groups for filtering downloads.
|
||||
"""
|
||||
|
||||
@@ -38,13 +37,16 @@ class KnownNamesFilterDialog(QDialog):
|
||||
if app_icon and not app_icon.isNull():
|
||||
self.setWindowIcon(app_icon)
|
||||
|
||||
# Set window size dynamically
|
||||
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||||
# --- START OF FIX ---
|
||||
# Get the user-defined scale factor from the parent application
|
||||
# instead of calculating an independent one.
|
||||
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
|
||||
|
||||
# Define base size and apply the correct scale factor
|
||||
base_width, base_height = 460, 450
|
||||
scale_factor_h = screen_geometry.height() / 1080.0
|
||||
effective_scale_factor = max(0.75, min(scale_factor_h, 1.5))
|
||||
self.setMinimumSize(int(base_width * effective_scale_factor), int(base_height * effective_scale_factor))
|
||||
self.resize(int(base_width * effective_scale_factor * 1.1), int(base_height * effective_scale_factor * 1.1))
|
||||
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
|
||||
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
|
||||
# --- END OF FIX ---
|
||||
|
||||
# --- Initialize UI and Apply Theming ---
|
||||
self._init_ui()
|
||||
@@ -153,4 +155,4 @@ class KnownNamesFilterDialog(QDialog):
|
||||
|
||||
def get_selected_entries(self):
|
||||
"""Returns the list of known name entries selected by the user."""
|
||||
return self.selected_entries_to_return
|
||||
return self.selected_entries_to_return
|
||||
@@ -1,34 +1,33 @@
|
||||
# SinglePDF.py
|
||||
|
||||
import os
|
||||
import re
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
FPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
FPDF_AVAILABLE = False
|
||||
|
||||
def strip_html_tags(text):
|
||||
if not text:
|
||||
return ""
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
class PDF(FPDF):
|
||||
"""Custom PDF class to handle headers and footers."""
|
||||
def header(self):
|
||||
# No header
|
||||
pass
|
||||
pass
|
||||
|
||||
def footer(self):
|
||||
# Position at 1.5 cm from bottom
|
||||
self.set_y(-15)
|
||||
self.set_font('DejaVu', '', 8)
|
||||
# Page number
|
||||
if self.font_family:
|
||||
self.set_font(self.font_family, '', 8)
|
||||
else:
|
||||
self.set_font('Arial', '', 8)
|
||||
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
|
||||
|
||||
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
|
||||
"""
|
||||
Creates a single PDF from a list of post titles and content.
|
||||
|
||||
Args:
|
||||
posts_data (list): A list of dictionaries, where each dict has 'title' and 'content' keys.
|
||||
output_filename (str): The full path for the output PDF file.
|
||||
font_path (str): Path to the DejaVuSans.ttf font file.
|
||||
logger (function, optional): A function to log progress and errors. Defaults to print.
|
||||
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
|
||||
"""
|
||||
if not FPDF_AVAILABLE:
|
||||
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
|
||||
@@ -39,34 +38,66 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return False
|
||||
|
||||
pdf = PDF()
|
||||
default_font_family = 'DejaVu'
|
||||
|
||||
bold_font_path = ""
|
||||
if font_path:
|
||||
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
|
||||
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
raise RuntimeError("Font file not found.")
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', font_path, uni=True) # Add Bold variant
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}")
|
||||
logger(" PDF may not support all characters. Falling back to default Arial font.")
|
||||
pdf.set_font('Arial', '', 12)
|
||||
pdf.set_font('Arial', 'B', 16)
|
||||
|
||||
logger(f" Starting PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for post in posts_data:
|
||||
pdf.add_page()
|
||||
# Post Title
|
||||
pdf.set_font('DejaVu', 'B', 16)
|
||||
|
||||
# vvv THIS LINE IS CORRECTED vvv
|
||||
# We explicitly set align='L' and remove the incorrect positional arguments.
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
|
||||
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
|
||||
|
||||
pdf.ln(5) # Add a little space after the title
|
||||
pdf.add_font('DejaVu', '', font_path, uni=True)
|
||||
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
|
||||
except Exception as font_error:
|
||||
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
|
||||
default_font_family = 'Arial'
|
||||
|
||||
pdf.add_page()
|
||||
|
||||
# Post Content
|
||||
pdf.set_font('DejaVu', '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
|
||||
|
||||
for i, post in enumerate(posts_data):
|
||||
if i > 0:
|
||||
if 'content' in post:
|
||||
pdf.add_page()
|
||||
elif 'comments' in post:
|
||||
pdf.ln(10)
|
||||
pdf.cell(0, 0, '', border='T')
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, 'B', 16)
|
||||
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
|
||||
pdf.ln(5)
|
||||
|
||||
if 'comments' in post and post['comments']:
|
||||
comments_list = post['comments']
|
||||
for comment_index, comment in enumerate(comments_list):
|
||||
user = comment.get('commenter_name', 'Unknown User')
|
||||
timestamp = comment.get('published', 'No Date')
|
||||
body = strip_html_tags(comment.get('content', ''))
|
||||
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, "Comment by: ")
|
||||
if user is not None:
|
||||
pdf.set_font(default_font_family, 'B', 10)
|
||||
pdf.write(8, str(user))
|
||||
|
||||
pdf.set_font(default_font_family, '', 10)
|
||||
pdf.write(8, f" on {timestamp}")
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font(default_font_family, '', 11)
|
||||
pdf.multi_cell(0, 7, body)
|
||||
|
||||
if comment_index < len(comments_list) - 1:
|
||||
pdf.ln(3)
|
||||
pdf.cell(w=0, h=0, border='T')
|
||||
pdf.ln(3)
|
||||
elif 'content' in post:
|
||||
pdf.set_font(default_font_family, '', 12)
|
||||
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
|
||||
|
||||
try:
|
||||
pdf.output(output_filename)
|
||||
@@ -74,4 +105,4 @@ def create_single_pdf_from_content(posts_data, output_filename, font_path, logge
|
||||
return True
|
||||
except Exception as e:
|
||||
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -1,15 +1,10 @@
|
||||
# --- Standard Library Imports ---
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- PyQt5 Imports ---
|
||||
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
|
||||
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
|
||||
)
|
||||
|
||||
# --- Local Application Imports ---
|
||||
from ...i18n.translator import get_translation
|
||||
from ..main_window import get_app_icon_object
|
||||
from ...utils.resolution import get_dark_theme
|
||||
@@ -58,8 +53,6 @@ class TourDialog(QDialog):
|
||||
"""
|
||||
tour_finished_normally = pyqtSignal()
|
||||
tour_skipped = pyqtSignal()
|
||||
|
||||
# Constants for QSettings
|
||||
CONFIG_APP_NAME_TOUR = "ApplicationTour"
|
||||
TOUR_SHOWN_KEY = "neverShowTourAgainV19"
|
||||
|
||||
@@ -98,8 +91,6 @@ class TourDialog(QDialog):
|
||||
|
||||
self.stacked_widget = QStackedWidget()
|
||||
main_layout.addWidget(self.stacked_widget, 1)
|
||||
|
||||
# Load content for each step
|
||||
steps_content = [
|
||||
("tour_dialog_step1_title", "tour_dialog_step1_content"),
|
||||
("tour_dialog_step2_title", "tour_dialog_step2_content"),
|
||||
@@ -120,8 +111,6 @@ class TourDialog(QDialog):
|
||||
self.stacked_widget.addWidget(step_widget)
|
||||
|
||||
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
|
||||
|
||||
# --- Bottom Controls ---
|
||||
bottom_controls_layout = QVBoxLayout()
|
||||
bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
|
||||
bottom_controls_layout.setSpacing(12)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ MAX_FILENAME_COMPONENT_LENGTH = 150
|
||||
|
||||
# Sets of file extensions for quick type checking
|
||||
IMAGE_EXTENSIONS = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
|
||||
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
|
||||
@@ -24,19 +24,14 @@ def setup_ui(main_app):
|
||||
Args:
|
||||
main_app: The instance of the main DownloaderApp.
|
||||
"""
|
||||
# --- START: Modified Scaling Logic ---
|
||||
# Force a fixed scale factor to disable UI scaling on high-DPI screens.
|
||||
scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0))
|
||||
main_app.scale_factor = scale
|
||||
|
||||
# --- Set the global font size for the application ---
|
||||
default_font = QApplication.font()
|
||||
base_font_size = 9 # Use a standard base size
|
||||
default_font.setPointSize(int(base_font_size * scale))
|
||||
main_app.setFont(default_font)
|
||||
# --- END: Modified Scaling Logic ---
|
||||
|
||||
# --- Set the global font size for the application ---
|
||||
default_font = QApplication.font()
|
||||
base_font_size = 9 # Use a standard base size
|
||||
default_font.setPointSize(int(base_font_size * scale))
|
||||
@@ -221,12 +216,10 @@ def setup_ui(main_app):
|
||||
checkboxes_group_layout.setSpacing(10)
|
||||
row1_layout = QHBoxLayout()
|
||||
row1_layout.setSpacing(10)
|
||||
main_app.skip_zip_checkbox = QCheckBox("Skip .zip")
|
||||
main_app.skip_zip_checkbox = QCheckBox("Skip archives")
|
||||
main_app.skip_zip_checkbox.setToolTip("Skip Common Archives (Eg.. Zip, Rar, 7z)")
|
||||
main_app.skip_zip_checkbox.setChecked(True)
|
||||
row1_layout.addWidget(main_app.skip_zip_checkbox)
|
||||
main_app.skip_rar_checkbox = QCheckBox("Skip .rar")
|
||||
main_app.skip_rar_checkbox.setChecked(True)
|
||||
row1_layout.addWidget(main_app.skip_rar_checkbox)
|
||||
main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
|
||||
row1_layout.addWidget(main_app.download_thumbnails_checkbox)
|
||||
main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images")
|
||||
@@ -246,16 +239,23 @@ def setup_ui(main_app):
|
||||
checkboxes_group_layout.addWidget(advanced_settings_label)
|
||||
advanced_row1_layout = QHBoxLayout()
|
||||
advanced_row1_layout.setSpacing(10)
|
||||
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Name/Title")
|
||||
main_app.use_subfolders_checkbox.setChecked(True)
|
||||
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
|
||||
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
|
||||
|
||||
# --- REORDERED CHECKBOXES ---
|
||||
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.setChecked(True)
|
||||
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
|
||||
|
||||
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.")
|
||||
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.setChecked(main_app.use_cookie_setting)
|
||||
main_app.cookie_text_input = QLineEdit()
|
||||
@@ -387,7 +387,7 @@ def setup_ui(main_app):
|
||||
main_app.link_search_input.setPlaceholderText("Search Links...")
|
||||
main_app.link_search_input.setVisible(False)
|
||||
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.setFixedWidth(int(30 * scale))
|
||||
log_title_layout.addWidget(main_app.link_search_button)
|
||||
|
||||
Reference in New Issue
Block a user